From 844e6060c6222c2a93691366cf10a4e0f6f125ea Mon Sep 17 00:00:00 2001 From: data Date: Mon, 23 Feb 2026 15:27:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20Offline-f=C3=A4hige=20Progressive?= =?UTF-8?q?=20Web=20App=20f=C3=BCr=20Elektriker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWA Mobile App für Schaltschrank-Dokumentation vor Ort: - Token-basierte Authentifizierung (15 Tage gültig) - Kundensuche mit Offline-Cache - Anlagen-Auswahl und Offline-Laden - Felder/Hutschienen/Automaten erfassen - Automatische Synchronisierung wenn wieder online - Installierbar auf dem Smartphone Home Screen - Touch-optimiertes Dark Mode Design - Quick-Select für Automaten-Werte (B16, C32, etc.) Schaltplan-Editor Verbesserungen: - Block Hover-Tooltip mit show_in_hover Feldern - Produktinfo mit Icon im Tooltip - Position und Breite in TE Neue Dateien: - pwa.php, pwa_auth.php - PWA Einstieg & Auth - ajax/pwa_api.php - PWA AJAX API - js/pwa.js, css/pwa.css - PWA App & Styles - sw.js, manifest.json - Service Worker & Manifest - img/pwa-icon-192.png, img/pwa-icon-512.png Version: 5.2.0 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 164 + COPYING | 621 + ChangeLog.md | 244 + README.md | 117 + admin/about.php | 118 + admin/anlage_systems.php | 349 + admin/anlage_types.php | 873 ++ admin/backup.php | 364 + admin/building_types.php | 351 + admin/busbar_types.php | 460 + admin/equipment_types.php | 984 ++ admin/medium_types.php | 332 + admin/setup.php | 412 + ajax/anlage.php | 158 + ajax/anlage_connection.php | 193 + ajax/anlage_docs.php | 61 + ajax/anlage_images.php | 57 + ajax/anlage_tooltip.php | 106 + ajax/audit_log.php | 153 + ajax/bom_generator.php | 250 + ajax/building_types.php | 121 + ajax/equipment.php | 687 + ajax/equipment_carrier.php | 188 + ajax/equipment_connection.php | 556 + ajax/equipment_panel.php | 201 + ajax/equipment_type_block_image.php | 190 + ajax/equipment_type_fields.php | 74 + ajax/equipment_type_icon.php | 148 + ajax/export_schematic_pdf.php | 717 + ajax/export_tree_pdf.php | 432 + ajax/favorite_update.php | 78 + ajax/field_autocomplete.php | 106 + ajax/file_preview.php | 101 + ajax/graph_data.php | 307 + ajax/graph_save_positions.php | 93 + ajax/icon_upload.php | 145 + ajax/medium_types.php | 119 + ajax/pwa_api.php | 333 + ajax/tree_config.php | 107 + ajax/type_fields.php | 61 + anlage_connection.php | 306 + build/buildzip.php | 316 + build/makepack-kundenkarte.conf | 11 + class/anlage.class.php | 763 + class/anlagebackup.class.php | 558 + class/anlageconnection.class.php | 385 + class/anlagefile.class.php | 447 + class/anlagetype.class.php | 386 + class/auditlog.class.php | 455 + class/buildingtype.class.php | 363 + class/busbartype.class.php | 391 + class/equipment.class.php | 514 + class/equipmentcarrier.class.php | 426 + class/equipmentconnection.class.php | 427 + class/equipmentpanel.class.php | 285 + class/equipmenttype.class.php | 394 + class/favoriteproduct.class.php | 802 ++ class/mediumtype.class.php | 383 + class/terminalbridge.class.php | 257 + core/modules/modKundenKarte.class.php | 776 + css/kundenkarte.css | 2813 ++++ css/kundenkarte_cytoscape.css | 402 + css/pwa.css | 982 ++ img/README.md | 14 + img/pwa-icon-192.png | Bin 0 -> 1979 bytes img/pwa-icon-512.png | Bin 0 -> 132489 bytes js/cose-base.js | 3214 +++++ js/cytoscape-cose-bilkent.js | 458 + js/cytoscape-dagre.js | 397 + js/cytoscape.min.js | 32 + js/dagre.min.js | 3809 +++++ js/kundenkarte.js | 11750 ++++++++++++++++ js/kundenkarte_cytoscape.js | 1155 ++ js/layout-base.js | 5230 +++++++ js/pathfinding.min.js | 1 + js/pwa.js | 932 ++ kundenkarteindex.php | 259 + langs/de_DE/kundenkarte.lang | 547 + langs/en_US/kundenkarte.lang | 295 + lib/graph_view.lib.php | 160 + lib/kundenkarte.lib.php | 248 + manifest.json | 27 + modulebuilder.txt | 3 + pwa.php | 238 + pwa_auth.php | 151 + sql/data.sql | 29 + sql/data_building_types.sql | 140 + sql/data_busbar_types.sql | 49 + sql/data_medium_types.sql | 92 + sql/data_terminal_types.sql | 110 + sql/dolibarr_allversions.sql | 52 + sql/llx_c_kundenkarte_anlage_system.key.sql | 6 + sql/llx_c_kundenkarte_anlage_system.sql | 24 + sql/llx_kundenkarte_anlage.key.sql | 16 + sql/llx_kundenkarte_anlage.sql | 47 + sql/llx_kundenkarte_anlage_connection.key.sql | 7 + sql/llx_kundenkarte_anlage_connection.sql | 45 + sql/llx_kundenkarte_anlage_contact.sql | 8 + sql/llx_kundenkarte_anlage_files.key.sql | 11 + sql/llx_kundenkarte_anlage_files.sql | 36 + sql/llx_kundenkarte_anlage_type.key.sql | 10 + sql/llx_kundenkarte_anlage_type.sql | 37 + sql/llx_kundenkarte_anlage_type_field.key.sql | 10 + sql/llx_kundenkarte_anlage_type_field.sql | 36 + sql/llx_kundenkarte_audit_log.key.sql | 8 + sql/llx_kundenkarte_audit_log.sql | 43 + sql/llx_kundenkarte_building_type.key.sql | 7 + sql/llx_kundenkarte_building_type.sql | 42 + sql/llx_kundenkarte_busbar_type.key.sql | 14 + sql/llx_kundenkarte_busbar_type.sql | 46 + sql/llx_kundenkarte_equipment.key.sql | 10 + sql/llx_kundenkarte_equipment.sql | 36 + sql/llx_kundenkarte_equipment_carrier.key.sql | 8 + sql/llx_kundenkarte_equipment_carrier.sql | 26 + ...x_kundenkarte_equipment_connection.key.sql | 5 + sql/llx_kundenkarte_equipment_connection.sql | 64 + sql/llx_kundenkarte_equipment_panel.sql | 28 + sql/llx_kundenkarte_equipment_type.key.sql | 10 + sql/llx_kundenkarte_equipment_type.sql | 47 + ...x_kundenkarte_equipment_type_field.key.sql | 8 + sql/llx_kundenkarte_equipment_type_field.sql | 33 + sql/llx_kundenkarte_favorite_products.key.sql | 12 + sql/llx_kundenkarte_favorite_products.sql | 32 + ..._kundenkarte_favorite_products_contact.sql | 8 + sql/llx_kundenkarte_medium_type.key.sql | 7 + sql/llx_kundenkarte_medium_type.sql | 47 + sql/llx_kundenkarte_societe_system.key.sql | 12 + sql/llx_kundenkarte_societe_system.sql | 19 + ...llx_kundenkarte_societe_system_contact.sql | 8 + sql/llx_kundenkarte_terminal_bridge.key.sql | 2 + sql/llx_kundenkarte_terminal_bridge.sql | 42 + sql/update_3.0.0.sql | 12 + sql/update_3.1.0.sql | 41 + sql/update_3.2.0.sql | 78 + sql/update_3.3.0.sql | 330 + sql/update_3.3.2.sql | 182 + sql/update_3.4.1.sql | 18 + sql/update_3.6.0.sql | 16 + sw.js | 149 + tabs/anlagen.php | 2519 ++++ tabs/contact_anlagen.php | 1621 +++ tabs/contact_favoriteproducts.php | 369 + tabs/favoriteproducts.php | 369 + 143 files changed, 59986 insertions(+) create mode 100755 CLAUDE.md create mode 100755 COPYING create mode 100755 ChangeLog.md create mode 100755 README.md create mode 100755 admin/about.php create mode 100755 admin/anlage_systems.php create mode 100755 admin/anlage_types.php create mode 100755 admin/backup.php create mode 100755 admin/building_types.php create mode 100755 admin/busbar_types.php create mode 100755 admin/equipment_types.php create mode 100755 admin/medium_types.php create mode 100755 admin/setup.php create mode 100755 ajax/anlage.php create mode 100755 ajax/anlage_connection.php create mode 100755 ajax/anlage_docs.php create mode 100755 ajax/anlage_images.php create mode 100755 ajax/anlage_tooltip.php create mode 100755 ajax/audit_log.php create mode 100755 ajax/bom_generator.php create mode 100755 ajax/building_types.php create mode 100755 ajax/equipment.php create mode 100755 ajax/equipment_carrier.php create mode 100755 ajax/equipment_connection.php create mode 100755 ajax/equipment_panel.php create mode 100755 ajax/equipment_type_block_image.php create mode 100755 ajax/equipment_type_fields.php create mode 100755 ajax/equipment_type_icon.php create mode 100755 ajax/export_schematic_pdf.php create mode 100755 ajax/export_tree_pdf.php create mode 100755 ajax/favorite_update.php create mode 100755 ajax/field_autocomplete.php create mode 100755 ajax/file_preview.php create mode 100755 ajax/graph_data.php create mode 100755 ajax/graph_save_positions.php create mode 100755 ajax/icon_upload.php create mode 100755 ajax/medium_types.php create mode 100644 ajax/pwa_api.php create mode 100755 ajax/tree_config.php create mode 100755 ajax/type_fields.php create mode 100755 anlage_connection.php create mode 100755 build/buildzip.php create mode 100755 build/makepack-kundenkarte.conf create mode 100755 class/anlage.class.php create mode 100755 class/anlagebackup.class.php create mode 100755 class/anlageconnection.class.php create mode 100755 class/anlagefile.class.php create mode 100755 class/anlagetype.class.php create mode 100755 class/auditlog.class.php create mode 100755 class/buildingtype.class.php create mode 100755 class/busbartype.class.php create mode 100755 class/equipment.class.php create mode 100755 class/equipmentcarrier.class.php create mode 100755 class/equipmentconnection.class.php create mode 100755 class/equipmentpanel.class.php create mode 100755 class/equipmenttype.class.php create mode 100755 class/favoriteproduct.class.php create mode 100755 class/mediumtype.class.php create mode 100755 class/terminalbridge.class.php create mode 100755 core/modules/modKundenKarte.class.php create mode 100755 css/kundenkarte.css create mode 100755 css/kundenkarte_cytoscape.css create mode 100644 css/pwa.css create mode 100755 img/README.md create mode 100644 img/pwa-icon-192.png create mode 100644 img/pwa-icon-512.png create mode 100755 js/cose-base.js create mode 100755 js/cytoscape-cose-bilkent.js create mode 100755 js/cytoscape-dagre.js create mode 100755 js/cytoscape.min.js create mode 100755 js/dagre.min.js create mode 100755 js/kundenkarte.js create mode 100755 js/kundenkarte_cytoscape.js create mode 100755 js/layout-base.js create mode 100755 js/pathfinding.min.js create mode 100644 js/pwa.js create mode 100755 kundenkarteindex.php create mode 100755 langs/de_DE/kundenkarte.lang create mode 100755 langs/en_US/kundenkarte.lang create mode 100755 lib/graph_view.lib.php create mode 100755 lib/kundenkarte.lib.php create mode 100644 manifest.json create mode 100755 modulebuilder.txt create mode 100644 pwa.php create mode 100644 pwa_auth.php create mode 100755 sql/data.sql create mode 100755 sql/data_building_types.sql create mode 100755 sql/data_busbar_types.sql create mode 100755 sql/data_medium_types.sql create mode 100755 sql/data_terminal_types.sql create mode 100755 sql/dolibarr_allversions.sql create mode 100755 sql/llx_c_kundenkarte_anlage_system.key.sql create mode 100755 sql/llx_c_kundenkarte_anlage_system.sql create mode 100755 sql/llx_kundenkarte_anlage.key.sql create mode 100755 sql/llx_kundenkarte_anlage.sql create mode 100755 sql/llx_kundenkarte_anlage_connection.key.sql create mode 100755 sql/llx_kundenkarte_anlage_connection.sql create mode 100755 sql/llx_kundenkarte_anlage_contact.sql create mode 100755 sql/llx_kundenkarte_anlage_files.key.sql create mode 100755 sql/llx_kundenkarte_anlage_files.sql create mode 100755 sql/llx_kundenkarte_anlage_type.key.sql create mode 100755 sql/llx_kundenkarte_anlage_type.sql create mode 100755 sql/llx_kundenkarte_anlage_type_field.key.sql create mode 100755 sql/llx_kundenkarte_anlage_type_field.sql create mode 100755 sql/llx_kundenkarte_audit_log.key.sql create mode 100755 sql/llx_kundenkarte_audit_log.sql create mode 100755 sql/llx_kundenkarte_building_type.key.sql create mode 100755 sql/llx_kundenkarte_building_type.sql create mode 100755 sql/llx_kundenkarte_busbar_type.key.sql create mode 100755 sql/llx_kundenkarte_busbar_type.sql create mode 100755 sql/llx_kundenkarte_equipment.key.sql create mode 100755 sql/llx_kundenkarte_equipment.sql create mode 100755 sql/llx_kundenkarte_equipment_carrier.key.sql create mode 100755 sql/llx_kundenkarte_equipment_carrier.sql create mode 100755 sql/llx_kundenkarte_equipment_connection.key.sql create mode 100755 sql/llx_kundenkarte_equipment_connection.sql create mode 100755 sql/llx_kundenkarte_equipment_panel.sql create mode 100755 sql/llx_kundenkarte_equipment_type.key.sql create mode 100755 sql/llx_kundenkarte_equipment_type.sql create mode 100755 sql/llx_kundenkarte_equipment_type_field.key.sql create mode 100755 sql/llx_kundenkarte_equipment_type_field.sql create mode 100755 sql/llx_kundenkarte_favorite_products.key.sql create mode 100755 sql/llx_kundenkarte_favorite_products.sql create mode 100755 sql/llx_kundenkarte_favorite_products_contact.sql create mode 100755 sql/llx_kundenkarte_medium_type.key.sql create mode 100755 sql/llx_kundenkarte_medium_type.sql create mode 100755 sql/llx_kundenkarte_societe_system.key.sql create mode 100755 sql/llx_kundenkarte_societe_system.sql create mode 100755 sql/llx_kundenkarte_societe_system_contact.sql create mode 100755 sql/llx_kundenkarte_terminal_bridge.key.sql create mode 100755 sql/llx_kundenkarte_terminal_bridge.sql create mode 100755 sql/update_3.0.0.sql create mode 100755 sql/update_3.1.0.sql create mode 100755 sql/update_3.2.0.sql create mode 100755 sql/update_3.3.0.sql create mode 100755 sql/update_3.3.2.sql create mode 100755 sql/update_3.4.1.sql create mode 100755 sql/update_3.6.0.sql create mode 100644 sw.js create mode 100755 tabs/anlagen.php create mode 100755 tabs/contact_anlagen.php create mode 100755 tabs/contact_favoriteproducts.php create mode 100755 tabs/favoriteproducts.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..2daee7d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,164 @@ +CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 + +# KundenKarte Module - Entwicklungshinweise + +## Dolibarr App Navigation (Vor/Zurück Pfeile) + +### Problem +Die Dolibarr Mobile App hat eigene Navigations-Pfeile (vorheriger/nächster Kunde), die zwischen Datensätzen navigieren. Diese verwenden andere Parameter als unsere Module erwarten: + +- **Kunden-Navigation**: Dolibarr verwendet `socid`, Module erwarten oft `id` +- **Kontakt-Navigation**: Dolibarr verwendet `contactid`, Module erwarten oft `id` + +Wenn man diese Pfeile auf einem Modul-Tab verwendet, verliert das Modul die ID und zeigt einen Fehler oder leere Seite. + +### Lösung: Beide Parameter akzeptieren + +In **JEDEM Tab-PHP-File** muss am Anfang beide Parameter akzeptiert werden: + +**Für Kunden-Tabs (thirdparty):** +```php +// Get parameters +// Support both 'id' and 'socid' for compatibility with Dolibarr's customer navigation arrows +$id = GETPOSTINT('id'); +if ($id <= 0) { + $id = GETPOSTINT('socid'); +} +``` + +**Für Kontakt-Tabs (contact):** +```php +// Get parameters +// Support both 'id' and 'contactid' for compatibility with Dolibarr's contact navigation arrows +$id = GETPOSTINT('id'); +if ($id <= 0) { + $id = GETPOSTINT('contactid'); +} +``` + +### Betroffene Dateien in diesem Modul +- `tabs/anlagen.php` - Kunden-Anlagen (socid) +- `tabs/favoriteproducts.php` - Kunden-Favoriten (socid) +- `tabs/contact_anlagen.php` - Kontakt-Anlagen (contactid) +- `tabs/contact_favoriteproducts.php` - Kontakt-Favoriten (contactid) + +### Best Practices für zukünftige Module + +1. **IMMER beide Parameter akzeptieren** - `id` UND `socid`/`contactid` +2. **Tab-Definition in modXxx.class.php** verwendet `?id=__ID__` - das ist korrekt +3. **Dolibarr's `dol_banner_tab()`** generiert die Navigationspfeile mit `socid`/`contactid` +4. **Fallback bei fehlender ID** - zur Liste weiterleiten, nicht Fehler zeigen: +```php +if ($id <= 0) { + header('Location: '.DOL_URL_ROOT.'/societe/list.php'); + exit; +} +``` + +## Mobile Ansicht - Einheitliche Button-Größen + +### Problem +Auf mobilen Geräten haben die Buttons (Kompakt, Aufklappen, Einklappen, PDF Export) unterschiedliche Größen. + +### Lösung +CSS Media Queries für einheitliche Button-Größen: + +```css +@media (max-width: 768px) { + .kundenkarte-tree-controls { + justify-content: center !important; + flex-wrap: wrap !important; + gap: 8px !important; + } + + .kundenkarte-tree-controls .button { + flex: 1 1 auto !important; + min-width: 80px !important; + max-width: 150px !important; + padding: 10px 8px !important; + font-size: 12px !important; + text-align: center !important; + } +} + +@media (max-width: 480px) { + /* 2x2 Grid auf sehr kleinen Bildschirmen */ + .kundenkarte-tree-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + } +} +``` + +## Migrationen + +Alle Datenbankänderungen werden als idempotente Migrationen in `modKundenKarte.class.php` implementiert: +- `runMigrations()` wird bei jeder Modulaktivierung aufgerufen +- Jede Migration prüft zuerst, ob die Änderung bereits existiert +- Später werden Migrationen entfernt und Tabellen direkt korrekt erstellt + +## Dateistruktur + +- `tabs/anlagen.php` - Hauptansicht für Anlagen auf Kundenebene +- `tabs/contact_anlagen.php` - Anlagen für Kontakte +- `tabs/favoriteproducts.php` - Lieblingsprodukte auf Kundenebene +- `tabs/contact_favoriteproducts.php` - Lieblingsprodukte für Kontakte +- `admin/anlage_types.php` - Verwaltung der Element-Typen +- `ajax/` - AJAX-Endpunkte für dynamische Funktionen +- `js/kundenkarte.js` - Alle JavaScript-Komponenten +- `css/kundenkarte.css` - Alle Styles (Dark Mode) + +## Wichtige Hinweise + +### FontAwesome Icons +- Dolibarr verwendet FontAwesome 4.x Format: `fa fa-icon-name` +- NICHT: `fas fa-icon-name` oder `far fa-icon-name` + +### Badge-Farben +- Können pro Feld in Admin > Element-Typen konfiguriert werden +- Spalte `badge_color` in `llx_kundenkarte_anlage_type_field` +- Hex-Format: `#RRGGBB` + +### Datei-Vorschau Tooltip +- AJAX-Endpoint: `ajax/file_preview.php` +- Zeigt Thumbnails für Bilder, Icons für Dokumente +- Hover über Datei-Badge im Baum + +## PWA Mobile App + +### Übersicht +Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentation vor Ort. + +### Dateien +- `pwa.php` - Haupteinstieg (HTML/CSS/JS Container) +- `pwa_auth.php` - Token-basierte Authentifizierung (15 Tage gültig) +- `ajax/pwa_api.php` - Alle AJAX-Endpoints für die PWA +- `js/pwa.js` - Komplette App-Logik (vanilla JS, kein jQuery) +- `css/pwa.css` - Mobile-First Dark Mode Design +- `sw.js` - Service Worker für Offline-Cache +- `manifest.json` - Web App Manifest für Installation + +### Workflow +1. Login mit Dolibarr-Credentials → Token wird lokal gespeichert +2. Kunde suchen → Anlagen werden gecached +3. Anlage mit Schaltplan-Editor auswählen → Daten werden gecached +4. Offline arbeiten: Felder, Hutschienen, Automaten hinzufügen +5. Änderungen werden in lokaler Queue gespeichert +6. Bei Internetverbindung: Automatische Synchronisierung + +### Token-Authentifizierung +- Tokens enthalten: user_id, login, created, expires, hash +- Hash = MD5(user_id + login + MAIN_SECURITY_SALT) +- Gültigkeit: 15 Tage +- Gespeichert in localStorage + +### Offline-Sync +- Alle Änderungen werden in `offlineQueue` (localStorage) gespeichert +- Badge zeigt Anzahl ungesyncte Änderungen +- Sync-Button oder automatisch bei Online-Event +- Bei Sync werden Aktionen der Reihe nach ausgeführt + +### Installation auf Smartphone +1. PWA im Browser öffnen: `https://domain/dolibarr/custom/kundenkarte/pwa.php` +2. Browser-Menü → "Zum Startbildschirm hinzufügen" +3. App öffnet sich als Standalone ohne Browser-UI diff --git a/COPYING b/COPYING new file mode 100755 index 0000000..94a0453 --- /dev/null +++ b/COPYING @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100755 index 0000000..fc0dc69 --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,244 @@ +# CHANGELOG MODULE KUNDENKARTE FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) + +## 5.2.0 (2026-02) + +### Neue Features + +- **PWA Mobile App**: Offline-faehige Progressive Web App fuer Elektriker + - Schaltschrank-Dokumentation direkt vor Ort im Keller (ohne Internet) + - Kundensuche, Anlagen-Auswahl, Felder/Hutschienen/Automaten erfassen + - Token-basierte Authentifizierung (15 Tage gueltig) + - Automatische Synchronisierung wenn wieder online + - Installierbar auf dem Smartphone Home Screen + - Touch-optimiertes Dark Mode Design + - Quick-Select fuer Automaten-Werte (B16, C32, etc.) + +- **Schaltplan-Editor: Block Hover-Tooltip** + - Zeigt alle Felder mit `show_in_hover=1` Einstellung + - Produktinfo mit Icon wenn Produkt zugewiesen + - Position und Breite in TE + - Schickes dunkles Design + +- **Schaltplan-Editor: Produkt-Zuordnung** + - Autocomplete-Suche fuer Produkte beim Hinzufuegen/Bearbeiten + - Produktinfo im Klick-Popup angezeigt + +### Verbesserungen + +- Equipment-Popup zeigt jetzt Produktinfo mit Cube-Icon +- Loeschbestaetigung fuer Panels und Hutschienen mit Inhalt + +### Neue Dateien +- `pwa.php` - PWA Haupteinstieg +- `pwa_auth.php` - Token-basierte Authentifizierung +- `ajax/pwa_api.php` - PWA AJAX API +- `js/pwa.js` - PWA JavaScript App +- `css/pwa.css` - PWA Mobile Styles +- `sw.js` - Service Worker (Offline-Cache) +- `manifest.json` - PWA Web App Manifest +- `img/pwa-icon-192.png`, `img/pwa-icon-512.png` - App Icons + +--- + +## 5.1.0 (2026-02) + +### Verbesserungen +- **Graph-Ansicht: Intelligente Feldanzeige** + - Felder nach `position` sortiert (nicht mehr nach JSON-Reihenfolge) + - Nur Felder mit `show_in_tree=1` werden auf den Graph-Nodes angezeigt + - Nur Felder mit `show_in_hover=1` erscheinen im Tooltip + - Badge-Werte im Graph mit Feldbezeichnung (z.B. "Hersteller: ABB") + - Tooltip: Typ/System entfernt (redundant mit Graph-Node) + - Tooltip: Farbige Badge-Kaesten wie in der Baumansicht + - Shared Library `lib/graph_view.lib.php` fuer Toolbar/Container/Legende + +### Neue Dateien +- `lib/graph_view.lib.php` - Gemeinsame Graph-Funktionen (Toolbar, Container, Legende) + +--- + +## 5.0.0 (2026-02) + +### Neue Features +- **Cytoscape.js Graph-Ansicht**: Neue interaktive Netzwerk-Visualisierung + - Raeume als Compound-Container, Geraete als Nodes darin + - Kabelverbindungen als sichtbare Edges (auch raumuebergreifend) + - Durchgeschleifte Leitungen als gestrichelte Linien + - Dagre-Layout: Hierarchischer Stromfluss top-down + - Zoom/Pan/Fit-Controls, Mausrad-Zoom Toggle + - Kabeltyp-Legende mit Farben + - **Bearbeitungsmodus**: Nodes nur per "Anordnen"-Button verschiebbar, + Positionen per "Speichern"-Button fest, "Abbrechen" setzt zurueck + - Viewport-Persistenz (Zoom/Pan bleibt beim Seitenwechsel) + - Klick auf Node/Edge oeffnet Detail-/Bearbeitungsseite + - Suche als Overlay im Graph-Container (Nodes hervorheben/abdunkeln) + - Kontextmenue (Rechtsklick): Ansehen, Bearbeiten, Kopieren, Loeschen + - PNG-Export des Graphen + - Admin-Setting: Ansichtsmodus (Baum/Graph) in Setup waehlbar + - Toolbar zweizeilig: Aktionen oben, Graph-Steuerung unten + +- **Verbindungsformular verbessert** + - Select-Dropdowns zeigen nur Geraete (keine Gebaeude/Raeume) + - Icons (FontAwesome) in Select-Optionen via select2 + - Gebaeude-Pfad als Kontext (z.B. "EG > Zahlerschrank") + - Systemuebergreifende Geraete-Auswahl (kein Systemfilter) + +### Neue Dateien +- `js/kundenkarte_cytoscape.js` - Graph-Namespace (~750 Zeilen) +- `css/kundenkarte_cytoscape.css` - Graph-Styles mit Dark Mode +- `ajax/graph_data.php` - AJAX: Baum+Verbindungen → Cytoscape-Format +- `ajax/graph_save_positions.php` - AJAX: Node-Positionen speichern +- `js/cytoscape.min.js`, `js/dagre.min.js`, `js/cytoscape-dagre.js`, `js/cytoscape-cose-bilkent.js` - Bibliotheken + +### Bugfixes +- **Contact-Filter im Graph**: Graph zeigte faelschlicherweise Kontakt-Elemente auf Kunden-Ebene + - Fix: `fk_contact` Filter in `graph_data.php` analog zum Baum +- **Verbindung hinzufuegen**: Formular zeigte "Feld erforderlich"-Fehler beim Oeffnen + - Ursache: `action=create` in URL triggerte Handler vor Formular-Anzeige + - Fix: Korrekte Dolibarr-Konvention (create=Formular, add=Verarbeitung) +- **Leere Dropdowns**: Quelle/Ziel-Auswahl war leer wenn System keine Elemente hatte + - Fix: Kein System-Filter mehr (Kabel koennen systemuebergreifend sein) +- **Kontakt-Redirect**: Nach Verbindung-Bearbeiten landete man auf Kundenansicht statt Kontaktansicht + - Fix: `contactid` wird jetzt in allen Edit-URLs mitgegeben +- **Kontakt-Anlagen**: Auf Stand von Kunden-Anlagen gebracht + - tree_display_mode, badge_color, Schaltplan-Editor, Drag&Drop Upload + +### Datenbank-Aenderungen +- Neue Spalten `graph_x`, `graph_y` in `llx_kundenkarte_anlage` (Node-Positionen) +- Neue Spalte `fk_building_node` in `llx_kundenkarte_anlage` (vorbereitet fuer Phase 2) + +### Admin-Settings +- `KUNDENKARTE_DEFAULT_VIEW`: `tree` (Standard) oder `graph` + +--- + +## 4.0.1 (2026-02) + +### Neue Features +- **Badge-Farben pro Feld**: Individuelle Farben fuer Badges im Baum konfigurierbar + - Neue Spalte in Admin > Element-Typen > Felder + - Color-Picker fuer einfache Farbauswahl + - Hex-Format (#RRGGBB) + +- **Datei-Vorschau Tooltip**: Hover ueber Datei-Badge zeigt Vorschau + - Thumbnails fuer Bilder + - Icons fuer Dokumente (PDF, Word, Excel, etc.) + - Neuer AJAX-Endpoint `ajax/file_preview.php` + +- **Mobile/Kompakte Ansicht**: Optimiert fuer mobile Geraete + - Kompakt-Modus Toggle-Button + - Einheitliche Button-Groessen auf mobilen Geraeten + - 2x2 Grid-Layout auf sehr kleinen Bildschirmen + - Touch-freundliche Bedienelemente + +### Bugfixes +- **Dolibarr App Navigation**: Vor/Zurueck-Pfeile funktionieren jetzt korrekt + - Module akzeptieren nun `id` UND `socid`/`contactid` Parameter + - Kunden-Kontext bleibt beim Navigieren erhalten + - Betroffene Dateien: alle Tab-PHP-Files + +- **Datei-Badge Icon**: Zeigt jetzt Bueroklammer-Icon statt nur Zahl + +- **Kontakt-Anlagen Kategorie-Filter**: Fehlende Kategorie-Auswahl hinzugefuegt + - Gebaeude/Standort vs. Technisches Element jetzt auch bei Kontakten waehlbar + - Strom-Typen sind jetzt auswaehlbar + +- **Schematic Editor**: Hutschiene hinzufuegen Button bei leerem Panel + - Button erscheint jetzt auch wenn noch keine Hutschiene existiert + +- **Panel-Hoehe**: Korrektur der Berechnung bei mehreren Hutschienen + - Unterer Rand wird nicht mehr abgeschnitten + +### Datenbank-Aenderungen +- Neue Spalte `badge_color` in `llx_kundenkarte_anlage_type_field` + +--- + +## 3.5.0 (2026-02) + +### Neue Features +- **Drag & Drop Sortierung**: Elemente im Anlagenbaum per Drag & Drop umsortieren + - Geschwister-Elemente auf gleicher Ebene verschieben + - Visuelle Drop-Indikatoren (blaue Linie) + - Reihenfolge wird sofort per AJAX gespeichert (kein Seitenreload) + - Funktioniert in Kunden- und Kontakt-Anlagen + +### Bugfixes +- **Duplicate-Key-Fehler behoben**: UNIQUE KEY `uk_kundenkarte_societe_system` um `fk_contact` erweitert + - Systeme koennen nun gleichzeitig auf Kunden- und Kontaktebene existieren + - Migration wird automatisch beim Modul-Aktivieren ausgefuehrt + +### Verbesserungen +- Visueller Abstand zwischen Root-Elementen im Anlagenbaum +- INSERT fuer Kunden-Systeme setzt explizit `fk_contact = 0` + +--- + +## 2.0 (2026-01) + +### Neue Features +- **PDF Export mit Vorlage**: Briefpapier/Hintergrund-PDF kann als Vorlage hochgeladen werden + - Upload im Admin-Bereich unter Einstellungen + - Vorlage wird als Hintergrund auf allen Seiten verwendet +- **PDF Schriftgroessen konfigurierbar**: Anpassbare Schriftgroessen fuer den PDF-Export + - Ueberschriften (7-14pt) + - Inhalte (6-12pt) + - Feldbezeichnungen (5-10pt) +- **Verbesserte PDF-Baumdarstellung**: Professionelle Darstellung der Anlagenstruktur + - Farbcodierte Header pro Hierarchie-Ebene (dezente Grauabstufungen) + - Abgerundete Rahmen um Elemente + - Visuelle Verbindungslinien zwischen Elementen + - Bessere Einrueckung und Lesbarkeit + +### Verbesserungen +- Logo aus PDF-Export entfernt (ersetzt durch Vorlagen-System) +- Dynamische Felder fuer Element-Typen (Ueberschrift als neuer Feldtyp) +- Kopierfunktion fuer Elemente und Typen + +--- + +## 1.1 (2026-01) + +### Neue Features +- **Kontakt/Adressen-Unterstuetzung**: Favoriten und Anlagen koennen nun auch auf Kontakt-/Adressebene verwaltet werden + - Ideal fuer Kunden mit mehreren Gebaeuden/Standorten + - Neue Tabs "Favoriten" und "Anlagen" auf Kontaktkarten + - Vollstaendige Trennung der Daten zwischen Kunde und Kontakten + +### Verbesserungen +- Mengen-Eingabe bei Favoriten vereinfacht (Textfeld + Speichern-Button) +- Modul-Icon geaendert zu fa-id-card +- Dokumentation aktualisiert + +### Datenbank-Aenderungen +- Neue Spalte `fk_contact` in Tabelle `llx_kundenkarte_favorite_products` +- Neue Spalte `fk_contact` in Tabelle `llx_kundenkarte_anlage` +- Neue Spalte `fk_contact` in Tabelle `llx_kundenkarte_societe_system` + +### Hinweis zum Upgrade +Nach dem Update bitte das Modul einmal deaktivieren und wieder aktivieren, damit die SQL-Aenderungen ausgefuehrt werden. + +--- + +## 1.0 + +### Features +- Favoriten-Produkte fuer Kunden + - Produkte als Favoriten markieren + - Standardmengen festlegen + - Bestellungen aus Favoriten generieren + - Sortierbare Liste + +- Technische Anlagen (Baumstruktur) + - Systemkategorien (Strom, Internet, Kabel, Sat) + - Konfigurierbare Element-Typen + - Individuelle Felder pro Typ + - Datei-Upload mit Vorschau + - Hierarchische Struktur + +- Admin-Bereich + - Systeme verwalten + - Typen verwalten + - Felder konfigurieren + +Initial version diff --git a/README.md b/README.md new file mode 100755 index 0000000..3d7e066 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# KUNDENKARTE FOR [DOLIBARR ERP & CRM](https://www.dolibarr.org) + +## Features + +Das KundenKarte-Modul erweitert Dolibarr um zwei wichtige Funktionen fuer Kunden und deren Kontakte/Adressen: + +### Favoriten-Produkte +- Verwalten von Lieblingsprodukten pro Kunde oder Kontakt/Adresse +- Schnelle Bestellgenerierung aus Favoriten +- Individuelle Standardmengen pro Produkt +- Sortierbare Liste mit Drag & Drop oder Pfeiltasten + +### Technische Anlagen (Anlagen) +- Hierarchische Baumstruktur fuer technische Installationen +- Drag & Drop Sortierung der Elemente innerhalb einer Ebene +- Flexible Systemkategorien (z.B. Strom, Internet, Kabel, Sat) +- Kategorie-Auswahl beim Erstellen: Gebaeude/Standort oder Element/Geraet +- Typ-Select mit FontAwesome-Icons und Farbkodierung (Select2) +- Gebaeude-Typen gruppiert nach Ebene (Gebaeude, Etage, Fluegel, Raum, Bereich) +- Konfigurierbare Element-Typen mit individuellen Feldern +- Datei-Upload mit Bild-Vorschau und PDF-Anzeige +- Separate Verwaltung pro Kunde oder pro Kontakt/Adresse (z.B. verschiedene Gebaeude) +- Kabelverbindungen zwischen Anlagen-Elementen dokumentieren +- Visuelle Darstellung mit parallelen vertikalen Linien fuer jedes Kabel +- Automatische Gruppierung mit Abstaenden zwischen Kabel-Gruppen + +### Verteilungsdokumentation (Schaltplan-Editor) +- Interaktiver SVG-basierter Schaltplan-Editor +- Felder (Panels) und Hutschienen visuell verwalten +- Equipment-Bloecke per Drag & Drop positionieren +- Sammelschienen (Busbars) fuer Phasenverteilung mit konfigurierbaren Typen +- Phasenschienen per Drag & Drop verschiebbar (auch zwischen Hutschienen) +- Verbindungen zwischen Geraeten zeichnen (automatisch oder manuell) +- Abgaenge und Anschlusspunkte dokumentieren +- Klickbare Hutschienen zum Bearbeiten +- Zoom und Pan fuer grosse Schaltplaene +- Block-Bilder fuer Equipment-Typen (individuelle Darstellung) +- Reihenklemmen mit gestapelten Terminals (Mehrstockklemmen) +- Bruecken zwischen Reihenklemmen + +### PDF Export +- Export der Anlagenstruktur als PDF +- Upload einer PDF-Vorlage als Briefpapier/Hintergrund +- Konfigurierbare Schriftgroessen (Ueberschriften, Inhalte, Felder) +- Professionelle Baumdarstellung mit farbcodierten Ebenen und Rahmen + +### Kontakt/Adressen-Unterstuetzung +- Beide Funktionen (Favoriten + Anlagen) sind sowohl auf Kundenebene als auch auf Kontakt-/Adressebene verfuegbar +- Ideal fuer Kunden mit mehreren Standorten/Gebaeuden +- Vollstaendige Trennung der Daten zwischen Kunde und Kontakten + +## Tabs + +Das Modul fuegt folgende Tabs hinzu: + +| Tab | Objekt | Beschreibung | +|-----|--------|--------------| +| Favoriten | Kunde (Thirdparty) | Favoriten-Produkte fuer den Kunden | +| Favoriten | Kontakt/Adresse | Favoriten-Produkte fuer einen spezifischen Kontakt | +| Anlagen | Kunde (Thirdparty) | Technische Anlagen des Kunden | +| Anlagen | Kontakt/Adresse | Technische Anlagen eines spezifischen Kontakts/Gebaeudes | + +## Admin-Bereich + +Im Admin-Bereich (Home > Setup > Module > KundenKarte) koennen Sie: + +- **Anlagen-Systeme**: System-Kategorien anlegen (z.B. Strom, Internet) +- **Element-Typen**: Geraetetypen definieren (z.B. Zaehler, Router, Wallbox) +- **Typ-Felder**: Individuelle Felder pro Geraetetyp konfigurieren +- **Gebaeudetypen**: Strukturtypen (Haus, Etage, Raum etc.) fuer die Gebaeude-Hierarchie +- **Kabeltypen**: Verbindungsmedien (NYM, NYY, CAT etc.) mit Spezifikationen +- **Equipment-Typen**: Schaltplan-Komponenten (z.B. Sicherungsautomaten, FI-Schalter) mit Breite (TE), Farbe und Terminal-Konfiguration +- **Phasenschienen-Typen**: Sammelschienen/Phasenschienen-Vorlagen (L1, L2, L3, N, PE, 3P+N etc.) mit Farben und Linien-Konfiguration + +## Berechtigungen + +| Berechtigung | Beschreibung | +|--------------|--------------| +| kundenkarte read | Favoriten und Anlagen ansehen | +| kundenkarte write | Favoriten und Anlagen bearbeiten | +| kundenkarte delete | Favoriten und Anlagen loeschen | + +## Installation + +### Voraussetzungen +- Dolibarr ERP & CRM >= 19.0 +- PHP >= 7.1 + +### Installation via ZIP +1. ZIP-Datei herunterladen +2. In Dolibarr: Home > Setup > Module > Externes Modul deployen +3. ZIP-Datei hochladen +4. Modul aktivieren unter Home > Setup > Module + +### Manuelle Installation +1. Modul-Ordner in `/custom/kundenkarte` kopieren +2. In Dolibarr: Home > Setup > Module +3. Modul "KundenKarte" aktivieren + +### Nach der Aktivierung +- Die SQL-Tabellen werden automatisch erstellt +- Systemkategorien und Typen im Admin-Bereich anlegen +- Fertig! + +## Translations + +Uebersetzungen befinden sich in: +- `langs/de_DE/kundenkarte.lang` (Deutsch) +- `langs/en_US/kundenkarte.lang` (Englisch) + +## License + +GPLv3 or (at your option) any later version. See file COPYING for more information. + +## Author + +Alles Watt laeuft - Eduard Wisch diff --git a/admin/about.php b/admin/about.php new file mode 100755 index 0000000..6f2ab6d --- /dev/null +++ b/admin/about.php @@ -0,0 +1,118 @@ + + * Copyright (C) 2026 Eduard Wisch + * Copyright (C) 2024 Frédéric France + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file kundenkarte/admin/about.php + * \ingroup kundenkarte + * \brief About page of module KundenKarte. + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +require_once '../lib/kundenkarte.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("errors", "admin", "kundenkarte@kundenkarte")); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + + +/* + * Actions + */ + +// None + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "KundenKarteSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-kundenkarte page-admin_about'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'about', $langs->trans($title), 0, 'kundenkarte@kundenkarte'); + +dol_include_once('/kundenkarte/core/modules/modKundenKarte.class.php'); +$tmpmodule = new modKundenKarte($db); +print $tmpmodule->getDescLong(); + +// Page end +print dol_get_fiche_end(); +llxFooter(); +$db->close(); diff --git a/admin/anlage_systems.php b/admin/anlage_systems.php new file mode 100755 index 0000000..b4054a1 --- /dev/null +++ b/admin/anlage_systems.php @@ -0,0 +1,349 @@ +loadLangs(array('admin', 'kundenkarte@kundenkarte')); + +// Security check +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$systemId = GETPOSTINT('systemid'); + +$form = new Form($db); + +/* + * Actions + */ + +if ($action == 'add') { + $code = GETPOST('code', 'aZ09'); + $label = GETPOST('label', 'alphanohtml'); + $picto = GETPOST('picto', 'alphanohtml'); + $color = GETPOST('color', 'alphanohtml'); + $position = GETPOSTINT('position'); + + // Tree display config + $treeConfig = array( + 'show_ref' => GETPOSTINT('show_ref'), + 'show_label' => GETPOSTINT('show_label'), + 'show_type' => GETPOSTINT('show_type'), + 'show_icon' => GETPOSTINT('show_icon'), + 'show_status' => GETPOSTINT('show_status'), + 'show_fields' => GETPOSTINT('show_fields'), + 'expand_default' => GETPOSTINT('expand_default'), + 'indent_style' => GETPOST('indent_style', 'alpha') ?: 'lines' + ); + $treeConfigJson = json_encode($treeConfig); + + if (empty($code) || empty($label)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + } else { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system"; + $sql .= " (code, label, picto, color, position, active, entity, tree_display_config)"; + $sql .= " VALUES ('".$db->escape(strtoupper($code))."', '".$db->escape($label)."',"; + $sql .= " ".($picto ? "'".$db->escape($picto)."'" : "NULL").","; + $sql .= " ".($color ? "'".$db->escape($color)."'" : "NULL").","; + $sql .= " ".((int) $position).", 1, 0,"; + $sql .= " '".$db->escape($treeConfigJson)."')"; + + $result = $db->query($sql); + if ($result) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + } + $action = ''; +} + +if ($action == 'update') { + $code = GETPOST('code', 'aZ09'); + $label = GETPOST('label', 'alphanohtml'); + $picto = GETPOST('picto', 'alphanohtml'); + $color = GETPOST('color', 'alphanohtml'); + $position = GETPOSTINT('position'); + + // Tree display config + $treeConfig = array( + 'show_ref' => GETPOSTINT('show_ref'), + 'show_label' => GETPOSTINT('show_label'), + 'show_type' => GETPOSTINT('show_type'), + 'show_icon' => GETPOSTINT('show_icon'), + 'show_status' => GETPOSTINT('show_status'), + 'show_fields' => GETPOSTINT('show_fields'), + 'expand_default' => GETPOSTINT('expand_default'), + 'indent_style' => GETPOST('indent_style', 'alpha') ?: 'lines' + ); + $treeConfigJson = json_encode($treeConfig); + + $sql = "UPDATE ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system SET"; + $sql .= " code = '".$db->escape(strtoupper($code))."'"; + $sql .= ", label = '".$db->escape($label)."'"; + $sql .= ", picto = ".($picto ? "'".$db->escape($picto)."'" : "NULL"); + $sql .= ", color = ".($color ? "'".$db->escape($color)."'" : "NULL"); + $sql .= ", position = ".((int) $position); + $sql .= ", tree_display_config = '".$db->escape($treeConfigJson)."'"; + $sql .= " WHERE rowid = ".((int) $systemId); + + $result = $db->query($sql); + if ($result) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + $action = ''; +} + +if ($action == 'confirm_delete' && $confirm == 'yes') { + // Check if in use + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE fk_system = ".((int) $systemId); + $resql = $db->query($sql); + $obj = $db->fetch_object($resql); + + if ($obj->cnt > 0) { + setEventMessages($langs->trans('ErrorSystemInUse'), null, 'errors'); + } else { + // Also delete types for this system + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type WHERE fk_system = ".((int) $systemId); + $db->query($sql); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE rowid = ".((int) $systemId); + $result = $db->query($sql); + if ($result) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } + } + $action = ''; +} + +if ($action == 'activate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system SET active = 1 WHERE rowid = ".((int) $systemId); + $db->query($sql); + $action = ''; +} + +if ($action == 'deactivate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system SET active = 0 WHERE rowid = ".((int) $systemId); + $db->query($sql); + $action = ''; +} + +/* + * View + */ + +$title = $langs->trans('AnlagenSystems'); + +// Include CSS and JS +$morejs = array('/kundenkarte/js/kundenkarte.js?v=1769962608'); +$morecss = array('/kundenkarte/css/kundenkarte.css?v=1769962608'); + +llxHeader('', $title, '', '', 0, 0, $morejs, $morecss); + +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'systems', $langs->trans('ModuleKundenKarteName'), -1, 'fa-file'); + +// Confirmation +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?systemid='.$systemId, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteSystem'), + 'confirm_delete', + '', + 'yes', + 1 + ); +} + +// Add form +if ($action == 'create' || $action == 'edit') { + $system = null; + if ($action == 'edit' && $systemId > 0) { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE rowid = ".((int) $systemId); + $resql = $db->query($sql); + $system = $db->fetch_object($resql); + } + + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print '
'.$langs->trans('SystemCode').'
'.$langs->trans('SystemLabel').'
'.$langs->trans('SystemPicto').'
'; + print ''; + if ($system && $system->picto) { + print kundenkarte_render_icon($system->picto); + } + print ''; + print ''; + print ''; + print '
'.$langs->trans('SystemColor').'
'.$langs->trans('Position').'
'; + + // Tree display configuration + print '

'.$langs->trans('TreeDisplayConfig').'

'; + + // Parse existing config + $treeConfig = array( + 'show_ref' => 1, + 'show_label' => 1, + 'show_type' => 1, + 'show_icon' => 1, + 'show_status' => 1, + 'show_fields' => 0, + 'expand_default' => 1, + 'indent_style' => 'lines' + ); + if ($system && !empty($system->tree_display_config)) { + $savedConfig = json_decode($system->tree_display_config, true); + if (is_array($savedConfig)) { + $treeConfig = array_merge($treeConfig, $savedConfig); + } + } + + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print '
'.$langs->trans('TreeShowRef').' '; + print ''.$langs->trans('TreeShowRefHelp').'
'.$langs->trans('TreeShowLabel').' '; + print ''.$langs->trans('TreeShowLabelHelp').'
'.$langs->trans('TreeShowType').' '; + print ''.$langs->trans('TreeShowTypeHelp').'
'.$langs->trans('TreeShowIcon').' '; + print ''.$langs->trans('TreeShowIconHelp').'
'.$langs->trans('TreeShowStatus').' '; + print ''.$langs->trans('TreeShowStatusHelp').'
'.$langs->trans('TreeShowFields').' '; + print ''.$langs->trans('TreeShowFieldsHelp').'
'.$langs->trans('TreeExpandDefault').' '; + print ''.$langs->trans('TreeExpandDefaultHelp').'
'.$langs->trans('TreeIndentStyle').'
'; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + +} else { + // List + print ''; + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system ORDER BY position ASC, label ASC"; + $resql = $db->query($sql); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + print ''; + + print ''; + print ''; + print ''; + print ''; + + print ''; + + print ''; + + print ''; + } + } + + print '
'.$langs->trans('SystemCode').''.$langs->trans('SystemLabel').''.$langs->trans('SystemPicto').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
'.dol_escape_htmltag($obj->code).''.dol_escape_htmltag($obj->label).''; + if ($obj->picto) { + print kundenkarte_render_icon($obj->picto, '', 'color:'.$obj->color.';').' '; + print dol_escape_htmltag($obj->picto); + } + print ''.$obj->position.''; + if ($obj->active) { + print ''.img_picto($langs->trans('Enabled'), 'switch_on').''; + } else { + print ''.img_picto($langs->trans('Disabled'), 'switch_off').''; + } + print ''; + print ''.img_edit().''; + print ' '.img_delete().''; + print '
'; +} + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/admin/anlage_types.php b/admin/anlage_types.php new file mode 100755 index 0000000..10e727f --- /dev/null +++ b/admin/anlage_types.php @@ -0,0 +1,873 @@ +loadLangs(array('admin', 'kundenkarte@kundenkarte')); + +// Security check +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$typeId = GETPOSTINT('typeid'); + +// System filter - save in session for persistence +$sessionKey = 'kundenkarte_anlage_types_system_filter'; +if (GETPOSTISSET('system')) { + $systemFilter = GETPOSTINT('system'); + $_SESSION[$sessionKey] = $systemFilter; +} elseif (isset($_SESSION[$sessionKey])) { + $systemFilter = $_SESSION[$sessionKey]; +} else { + $systemFilter = 0; +} + +$form = new Form($db); +$anlageType = new AnlageType($db); + +// Load systems +$systems = array(); +$sql = "SELECT rowid, code, label FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $systems[$obj->rowid] = $obj; + } +} + +/* + * Actions + */ + +if ($action == 'add') { + $anlageType->ref = GETPOST('ref', 'aZ09'); + $anlageType->label = GETPOST('label', 'alphanohtml'); + $anlageType->label_short = GETPOST('label_short', 'alphanohtml'); + $anlageType->description = GETPOST('description', 'restricthtml'); + $anlageType->fk_system = GETPOSTINT('fk_system'); + $anlageType->can_have_children = GETPOSTINT('can_have_children'); + $anlageType->can_be_nested = GETPOSTINT('can_be_nested'); + $anlageType->allowed_parent_types = preg_replace('/[^A-Z0-9_,]/i', '', GETPOST('allowed_parent_types', 'nohtml')); + $anlageType->can_have_equipment = GETPOSTINT('can_have_equipment'); + $anlageType->picto = GETPOST('picto', 'alphanohtml'); + $anlageType->color = GETPOST('color', 'alphanohtml'); + $anlageType->position = GETPOSTINT('position'); + $anlageType->active = 1; + + if (empty($anlageType->ref) || empty($anlageType->label)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + $action = 'create'; + } else { + $result = $anlageType->create($user); + if ($result > 0) { + // Create default fields for the new type + $defaultFields = array( + array('code' => 'manufacturer', 'label' => 'Hersteller', 'type' => 'text', 'position' => 10, 'show_in_hover' => 1, 'show_in_tree' => 1), + array('code' => 'model', 'label' => 'Modell', 'type' => 'text', 'position' => 20, 'show_in_hover' => 1, 'show_in_tree' => 0), + array('code' => 'serial_number', 'label' => 'Seriennummer', 'type' => 'text', 'position' => 30, 'show_in_hover' => 1, 'show_in_tree' => 0), + array('code' => 'power_rating', 'label' => 'Leistung', 'type' => 'text', 'position' => 40, 'show_in_hover' => 1, 'show_in_tree' => 1), + array('code' => 'location', 'label' => 'Standort', 'type' => 'text', 'position' => 50, 'show_in_hover' => 1, 'show_in_tree' => 0), + array('code' => 'installation_date', 'label' => 'Installationsdatum', 'type' => 'date', 'position' => 60, 'show_in_hover' => 1, 'show_in_tree' => 0), + ); + + foreach ($defaultFields as $field) { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + $sql .= " (fk_anlage_type, field_code, field_label, field_type, field_options, show_in_tree, show_in_hover, required, position, active)"; + $sql .= " VALUES (".((int) $anlageType->id).", '".$db->escape($field['code'])."', '".$db->escape($field['label'])."',"; + $sql .= " '".$db->escape($field['type'])."', '', ".((int) $field['show_in_tree']).", ".((int) $field['show_in_hover']).", 0, ".((int) $field['position']).", 1)"; + $db->query($sql); + } + + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$anlageType->id.'&system='.$anlageType->fk_system); + exit; + } else { + setEventMessages($anlageType->error, $anlageType->errors, 'errors'); + $action = 'create'; + } + } +} + +if ($action == 'update') { + $anlageType->fetch($typeId); + $anlageType->ref = GETPOST('ref', 'aZ09'); + $anlageType->label = GETPOST('label', 'alphanohtml'); + $anlageType->label_short = GETPOST('label_short', 'alphanohtml'); + $anlageType->description = GETPOST('description', 'restricthtml'); + $anlageType->fk_system = GETPOSTINT('fk_system'); + $anlageType->can_have_children = GETPOSTINT('can_have_children'); + $anlageType->can_be_nested = GETPOSTINT('can_be_nested'); + $anlageType->allowed_parent_types = preg_replace('/[^A-Z0-9_,]/i', '', GETPOST('allowed_parent_types', 'nohtml')); + $anlageType->can_have_equipment = GETPOSTINT('can_have_equipment'); + $anlageType->picto = GETPOST('picto', 'alphanohtml'); + $anlageType->color = GETPOST('color', 'alphanohtml'); + $anlageType->position = GETPOSTINT('position'); + + $result = $anlageType->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?system='.$anlageType->fk_system); + exit; + } else { + setEventMessages($anlageType->error, $anlageType->errors, 'errors'); + $action = 'edit'; + } +} + +if ($action == 'confirm_delete' && $confirm == 'yes') { + $anlageType->fetch($typeId); + $result = $anlageType->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($anlageType->error, $anlageType->errors, 'errors'); + } + $action = ''; +} + +if ($action == 'activate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage_type SET active = 1 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +if ($action == 'deactivate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage_type SET active = 0 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +// Copy type with all fields +if ($action == 'copy' && $typeId > 0) { + $sourceType = new AnlageType($db); + if ($sourceType->fetch($typeId) > 0) { + // Create new type with copied data + $newType = new AnlageType($db); + $newType->ref = $sourceType->ref.'_COPY'; + $newType->label = $sourceType->label.' (Kopie)'; + $newType->label_short = $sourceType->label_short; + $newType->description = $sourceType->description; + $newType->fk_system = $sourceType->fk_system; + $newType->can_have_children = $sourceType->can_have_children; + $newType->can_be_nested = $sourceType->can_be_nested; + $newType->allowed_parent_types = $sourceType->allowed_parent_types; + $newType->can_have_equipment = $sourceType->can_have_equipment; + $newType->picto = $sourceType->picto; + $newType->color = $sourceType->color; + $newType->position = $sourceType->position + 1; + $newType->active = 1; + + $result = $newType->create($user); + if ($result > 0) { + // Copy all fields from source type + $sourceFields = $sourceType->fetchFields(0); // Get all fields including inactive + foreach ($sourceFields as $field) { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + $sql .= " (fk_anlage_type, field_code, field_label, field_type, field_options, show_in_tree, show_in_hover, required, position, active)"; + $sql .= " VALUES (".((int) $newType->id).", '".$db->escape($field->field_code)."', '".$db->escape($field->field_label)."',"; + $sql .= " '".$db->escape($field->field_type)."', '".$db->escape($field->field_options)."', ".((int) $field->show_in_tree).","; + $sql .= " ".((int) $field->show_in_hover).", ".((int) $field->required).", ".((int) $field->position).", ".((int) $field->active).")"; + $db->query($sql); + } + + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$newType->id.'&system='.$newType->fk_system); + exit; + } else { + setEventMessages($newType->error, $newType->errors, 'errors'); + } + } + $action = ''; +} + +// Field actions +$fieldId = GETPOSTINT('fieldid'); + +if ($action == 'add_field') { + $fieldCode = GETPOST('field_code', 'aZ09'); + $fieldLabel = GETPOST('field_label', 'alphanohtml'); + $fieldType = GETPOST('field_type', 'aZ09'); + $fieldOptions = GETPOST('field_options', 'nohtml'); + $showInTree = GETPOSTINT('show_in_tree'); + $treeDisplayMode = GETPOST('tree_display_mode', 'aZ09'); + if (empty($treeDisplayMode)) $treeDisplayMode = 'badge'; + $badgeColor = GETPOST('badge_color', 'alphanohtml'); + if ($badgeColor && !preg_match('/^#[0-9A-Fa-f]{6}$/', $badgeColor)) { + $badgeColor = ''; + } + $showInHover = GETPOSTINT('show_in_hover'); + $enableAutocomplete = GETPOSTINT('enable_autocomplete'); + $isRequired = GETPOSTINT('is_required'); + $fieldPosition = GETPOSTINT('field_position'); + + if (empty($fieldCode) || empty($fieldLabel) || empty($fieldType)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + } else { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + $sql .= " (fk_anlage_type, field_code, field_label, field_type, field_options, show_in_tree, tree_display_mode, badge_color, show_in_hover, enable_autocomplete, required, position, active)"; + $sql .= " VALUES (".((int) $typeId).", '".$db->escape($fieldCode)."', '".$db->escape($fieldLabel)."',"; + $sql .= " '".$db->escape($fieldType)."', '".$db->escape($fieldOptions)."',"; + $sql .= " ".((int) $showInTree).", '".$db->escape($treeDisplayMode)."', ".($badgeColor ? "'".$db->escape($badgeColor)."'" : "NULL").", ".((int) $showInHover).", ".((int) $enableAutocomplete).", ".((int) $isRequired).", ".((int) $fieldPosition).", 1)"; + + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + } + $action = 'edit'; +} + +if ($action == 'update_field') { + $fieldCode = GETPOST('field_code', 'aZ09'); + $fieldLabel = GETPOST('field_label', 'alphanohtml'); + $fieldType = GETPOST('field_type', 'aZ09'); + $fieldOptions = GETPOST('field_options', 'nohtml'); + $showInTree = GETPOSTINT('show_in_tree'); + $treeDisplayMode = GETPOST('tree_display_mode', 'aZ09'); + if (empty($treeDisplayMode)) $treeDisplayMode = 'badge'; + $badgeColor = GETPOST('badge_color', 'alphanohtml'); + // Validate color format (#RRGGBB) + if ($badgeColor && !preg_match('/^#[0-9A-Fa-f]{6}$/', $badgeColor)) { + $badgeColor = ''; + } + $showInHover = GETPOSTINT('show_in_hover'); + $enableAutocomplete = GETPOSTINT('enable_autocomplete'); + $isRequired = GETPOSTINT('is_required'); + $fieldPosition = GETPOSTINT('field_position'); + + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field SET"; + $sql .= " field_code = '".$db->escape($fieldCode)."',"; + $sql .= " field_label = '".$db->escape($fieldLabel)."',"; + $sql .= " field_type = '".$db->escape($fieldType)."',"; + $sql .= " field_options = '".$db->escape($fieldOptions)."',"; + $sql .= " show_in_tree = ".((int) $showInTree).","; + $sql .= " tree_display_mode = '".$db->escape($treeDisplayMode)."',"; + $sql .= " badge_color = ".($badgeColor ? "'".$db->escape($badgeColor)."'" : "NULL").","; + $sql .= " show_in_hover = ".((int) $showInHover).","; + $sql .= " enable_autocomplete = ".((int) $enableAutocomplete).","; + $sql .= " required = ".((int) $isRequired).","; + $sql .= " position = ".((int) $fieldPosition); + $sql .= " WHERE rowid = ".((int) $fieldId); + + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + $action = 'edit'; +} + +if ($action == 'confirm_delete_field' && $confirm == 'yes') { + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field WHERE rowid = ".((int) $fieldId); + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + // Redirect back to edit page to stay in the fields list + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$typeId.'&system='.$systemFilter); + exit; +} + +if ($action == 'activate_field') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field SET active = 1 WHERE rowid = ".((int) $fieldId); + $db->query($sql); + $action = 'edit'; +} + +if ($action == 'deactivate_field') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field SET active = 0 WHERE rowid = ".((int) $fieldId); + $db->query($sql); + $action = 'edit'; +} + +/* + * View + */ + +$title = $langs->trans('AnlagenTypes'); + +// Include CSS and JS +$morejs = array('/kundenkarte/js/kundenkarte.js?v=1769962608'); +$morecss = array('/kundenkarte/css/kundenkarte.css?v=1769962608'); + +llxHeader('', $title, '', '', 0, 0, $morejs, $morecss); + +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'types', $langs->trans('ModuleKundenKarteName'), -1, 'fa-file'); + +// Confirmation for type deletion +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId.'&system='.$systemFilter, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteType'), + 'confirm_delete', + '', + 'yes', + 1 + ); +} + +// Confirmation for field deletion +if ($action == 'delete_field') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId.'&fieldid='.$fieldId.'&system='.$systemFilter, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteField'), + 'confirm_delete_field', + '', + 'yes', + 1 + ); + $action = 'edit'; // Stay in edit mode to show the fields +} + +// Add/Edit form +if (in_array($action, array('create', 'edit'))) { + if ($action == 'edit' && $typeId > 0) { + $anlageType->fetch($typeId); + } + + // Get all types for parent selection + // We need to filter by the same system OR show all types if this type is for all systems + $allTypes = $anlageType->fetchAllBySystem(0, 0); // Get all types, we'll filter in the template + + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + // System + print ''; + print ''; + + // Reference + print ''; + print ''; + + // Label + print ''; + print ''; + + // Short label + print ''; + print ''; + + // Description + print ''; + print ''; + + // Can have children + print ''; + print ''; + + // Can be nested + print ''; + print ''; + + // Can have equipment (Hutschienen-Komponenten) + print ''; + print ''; + + // Allowed parent types - with multi-select UI + print ''; + print ''; + + // Icon + print ''; + print ''; + + // Position + print ''; + print ''; + + print '
'.$langs->trans('System').''; + print ' ('.$langs->trans('AllSystemsHint').')'; + print '
'.$langs->trans('TypeRef').''; + print ' (UPPERCASE, no spaces)
'.$langs->trans('TypeLabel').'
'.$langs->trans('TypeShortLabel').'
'.$langs->trans('Description').'
'.$langs->trans('CanHaveChildren').'can_have_children ? ' checked' : '').'>
'.$langs->trans('CanBeNested').'can_be_nested ? ' checked' : '').'>'; + print ' ('.$langs->trans('SameTypeUnderItself').')
'.$langs->trans('CanHaveEquipment').'can_have_equipment ? ' checked' : '').'>'; + print ' ('.$langs->trans('CanHaveEquipmentHelp').')
'.$langs->trans('AllowedParentTypes').''; + + // Hidden field to store the actual value + print ''; + + // Selection UI + print '
'; + + // Select dropdown with add button + // Get current type's system for filtering (when editing) + $currentTypeSystem = ($action == 'edit') ? $anlageType->fk_system : GETPOSTINT('fk_system'); + + print '
'; + print ''; + print ''; + print '
'; + + // List of selected parent types + print '
'; + // Will be filled by JavaScript + print '
'; + + print '
'; + + print '('.$langs->trans('AllowedParentTypesHelp').')'; + print '
'.$langs->trans('SystemPicto').'
'; + print ''; + if ($anlageType->picto) { + print kundenkarte_render_icon($anlageType->picto); + } + print ''; + print ''; + print ''; + print '
'.$langs->trans('Position').'
'; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + + // Fields management for existing type + if ($action == 'edit' && $typeId > 0) { + $editFieldId = GETPOSTINT('editfield'); + + print '

'; + print '

'.$langs->trans('AnlagenTypeFields').'

'; + + $fields = $anlageType->fetchFields(0); + + // Field types available + $fieldTypes = array( + 'header' => '── Überschrift ──', + 'text' => 'Textfeld (einzeilig)', + 'textarea' => 'Textfeld (mehrzeilig)', + 'number' => 'Zahlenfeld', + 'select' => 'Dropdown-Auswahl', + 'date' => 'Datumsfeld', + 'checkbox' => 'Checkbox (Ja/Nein)', + ); + + // Output edit forms BEFORE the table (forms cannot be inside tables) + foreach ($fields as $field) { + if ($editFieldId == $field->rowid) { + $formId = 'editfield_'.$field->rowid; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'; + } + } + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($fields as $field) { + // Check if we're editing this field + if ($editFieldId == $field->rowid) { + // Edit row - inputs linked to form outside table via form attribute + $formId = 'editfield_'.$field->rowid; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + $treeMode = $field->tree_display_mode ?? 'badge'; + print ''; + $badgeColor = $field->badge_color ?? ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } else { + // Display row + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + $treeMode = $field->tree_display_mode ?? 'badge'; + $treeModeIcon = ($treeMode == 'badge') ? 'B' : '(K)'; + print ''; + $badgeColor = $field->badge_color ?? ''; + if ($badgeColor) { + print ''; + } else { + print ''; + } + print ''; + $enableAutocomplete = isset($field->enable_autocomplete) ? $field->enable_autocomplete : 0; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } + + if (empty($fields)) { + print ''; + } + + print '
'.$langs->trans('FieldCode').''.$langs->trans('FieldLabel').''.$langs->trans('FieldType').''.$langs->trans('FieldOptions').''.$langs->trans('ShowInTree').''.$langs->trans('TreeDisplayMode').''.$langs->trans('ShowInHover').''.$langs->trans('IsRequired').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
show_in_tree ? ' checked' : '').'>show_in_hover ? ' checked' : '').'>enable_autocomplete ? ' checked' : '').' title="Autocomplete aktivieren">required ? ' checked' : '').'>'; + print '
'; + print ''; + print ''; + print '
'; + print '
'.dol_escape_htmltag($field->field_code).''.dol_escape_htmltag($field->field_label).''.dol_escape_htmltag($fieldTypes[$field->field_type] ?? $field->field_type).''.dol_escape_htmltag(dol_trunc($field->field_options, 20)).''.($field->show_in_tree ? img_picto('', 'tick') : '').''.$treeModeIcon.'-'.($field->show_in_hover ? img_picto('', 'tick') : '').''.($enableAutocomplete ? '' : '').''.($field->required ? img_picto('', 'tick') : '').''.$field->position.''; + if ($field->active) { + print ''.img_picto($langs->trans('Enabled'), 'switch_on').''; + } else { + print ''.img_picto($langs->trans('Disabled'), 'switch_off').''; + } + print ''; + print '
'; + print ''; + print ''; + print '
'; + print '
'.$langs->trans('NoFieldsDefined').'
'; + + // Add new field form - completely separate from table + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'.$langs->trans('Add').' '.$langs->trans('Field').'
'; + print '
'; + print '
'; + + // Help box for field options + print '
'; + print '

Hilfe: Feld-Optionen nach Feldtyp

'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
Textfeld (einzeilig)Keine Optionen nötig
Textfeld (mehrzeilig)Keine Optionen nötig
ZahlenfeldOptional: min:0|max:100|step:0.1
Dropdown-AuswahlPflicht! Optionen mit | trennen, z.B.: Option A|Option B|Option C
DatumsfeldKeine Optionen nötig
Checkbox (Ja/Nein)Keine Optionen nötig
'; + print '
'; + } + +} else { + // System filter + print '
'; + print $langs->trans('FilterBySystem').': '; + print ''; + print '
'; + + // Add button + print ''; + + // List + $types = $anlageType->fetchAllBySystem($systemFilter, 0, 1); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($types as $type) { + print ''; + + print ''; + + print ''; + + if (empty($type->fk_system)) { + print ''; + } else { + print ''; + } + print ''; + print ''; + + print ''; + + print ''; + + print ''; + } + + if (empty($types)) { + print ''; + } + + print '
'.$langs->trans('TypeRef').''.$langs->trans('TypeLabel').''.$langs->trans('System').''.$langs->trans('CanHaveChildren').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
'; + if ($type->picto) { + print kundenkarte_render_icon($type->picto).' '; + } + print dol_escape_htmltag($type->ref).''.dol_escape_htmltag($type->label); + if ($type->label_short) { + print ' ('.$type->label_short.')'; + } + print ''.$langs->trans('AllSystems').''.dol_escape_htmltag($type->system_label).''.($type->can_have_children ? img_picto('', 'tick') : '').''.$type->position.''; + if ($type->active) { + print ''.img_picto($langs->trans('Enabled'), 'switch_on').''; + } else { + print ''.img_picto($langs->trans('Disabled'), 'switch_off').''; + } + print ''; + print '
'; + print ''; + print ''; + if (!$type->is_system) { + print ''; + } + print '
'; + print '
'.$langs->trans('NoRecords').'
'; +} + +print dol_get_fiche_end(); + +// JavaScript for parent type selector +print ''; + +llxFooter(); +$db->close(); diff --git a/admin/backup.php b/admin/backup.php new file mode 100755 index 0000000..952d17f --- /dev/null +++ b/admin/backup.php @@ -0,0 +1,364 @@ +loadLangs(array("admin", "kundenkarte@kundenkarte")); + +// Access control +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); + +$form = new Form($db); +$backup = new AnlageBackup($db); + +/* + * Actions + */ + +// Create backup +if ($action == 'create_backup') { + $includeFiles = GETPOSTINT('include_files'); + + $result = $backup->createBackup($includeFiles); + if ($result) { + setEventMessages($langs->trans('BackupCreatedSuccess', basename($result)), null, 'mesgs'); + } else { + setEventMessages($langs->trans('BackupCreatedError').': '.$backup->error, null, 'errors'); + } + + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +// Download backup +if ($action == 'download') { + $filename = GETPOST('file', 'alpha'); + $filepath = $conf->kundenkarte->dir_output.'/backups/'.basename($filename); + + if (file_exists($filepath) && strpos($filename, 'kundenkarte_backup_') === 0) { + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="'.basename($filepath).'"'); + header('Content-Length: '.filesize($filepath)); + readfile($filepath); + exit; + } else { + setEventMessages($langs->trans('FileNotFound'), null, 'errors'); + } +} + +// Delete backup +if ($action == 'confirm_delete' && $confirm == 'yes') { + $filename = GETPOST('file', 'alpha'); + if ($backup->deleteBackup($filename)) { + setEventMessages($langs->trans('BackupDeleted'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('Error'), null, 'errors'); + } + + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +// Restore backup +if ($action == 'confirm_restore' && $confirm == 'yes') { + $filename = GETPOST('file', 'alpha'); + $clearExisting = GETPOSTINT('clear_existing'); + + $filepath = $conf->kundenkarte->dir_output.'/backups/'.basename($filename); + + if ($backup->restoreBackup($filepath, $clearExisting)) { + setEventMessages($langs->trans('RestoreSuccess'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('RestoreError').': '.$backup->error, null, 'errors'); + } + + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +// Upload backup +if ($action == 'upload_backup' && !empty($_FILES['backupfile']['name'])) { + $backupDir = $conf->kundenkarte->dir_output.'/backups'; + if (!is_dir($backupDir)) { + dol_mkdir($backupDir); + } + + $filename = $_FILES['backupfile']['name']; + + // Validate filename format + if (!preg_match('/^kundenkarte_backup_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip$/', $filename)) { + setEventMessages($langs->trans('InvalidBackupFile'), null, 'errors'); + } elseif ($_FILES['backupfile']['error'] !== UPLOAD_ERR_OK) { + setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors'); + } else { + $targetPath = $backupDir.'/'.$filename; + if (move_uploaded_file($_FILES['backupfile']['tmp_name'], $targetPath)) { + setEventMessages($langs->trans('BackupUploaded'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors'); + } + } + + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +/* + * View + */ + +$title = $langs->trans("BackupRestore"); +llxHeader('', $title); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; +print load_fiche_titre($title, $linkback, 'title_setup'); + +// Configuration header +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'backup', $langs->trans('ModuleKundenKarteName'), -1, "fa-address-card"); + +// Confirmation dialogs +if ($action == 'delete') { + $filename = GETPOST('file', 'alpha'); + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?file='.urlencode($filename), + $langs->trans('DeleteBackup'), + $langs->trans('ConfirmDeleteBackup', $filename), + 'confirm_delete', + '', + 'yes', + 1 + ); +} + +if ($action == 'restore') { + $filename = GETPOST('file', 'alpha'); + + $formquestion = array( + array( + 'type' => 'checkbox', + 'name' => 'clear_existing', + 'label' => $langs->trans('ClearExistingData'), + 'value' => 0 + ) + ); + + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?file='.urlencode($filename), + $langs->trans('RestoreBackup'), + $langs->trans('ConfirmRestoreBackup', $filename), + 'confirm_restore', + $formquestion, + 'yes', + 1 + ); +} + +// Statistics +$stats = $backup->getStatistics(); + +print '
'; + +// Stats cards +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalElements').''; +print ''.$stats['total_anlagen'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalFiles').''; +print ''.$stats['total_files'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalConnections').''; +print ''.$stats['total_connections'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('CustomersWithAnlagen').''; +print ''.$stats['total_customers'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('FilesStorageSize').''; +print ''.dol_print_size($stats['files_size']).''; +print '
'; +print '
'; + +print '
'; + +// Create Backup Section +print '
'.$langs->trans("CreateBackup").'
'; +print '

'; + +print '
'; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("BackupOptions").'
'.$langs->trans("IncludeUploadedFiles").''; +print ' '; +print ''.$langs->trans("IncludeFilesHelp").''; +print '
'; + +print '
'; +print '
'; +print ''; +print '
'; + +print '
'; + +// Upload Backup Section +print '

'; +print '
'.$langs->trans("UploadBackup").'
'; +print '

'; + +print '
'; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("UploadBackupFile").'
'.$langs->trans("SelectBackupFile").''; +print ''; +print ' '; +print '
'; + +print '
'; + +// Existing Backups Section +print '

'; +print '
'.$langs->trans("ExistingBackups").'
'; +print '

'; + +$backups = $backup->getBackupList(); + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +if (empty($backups)) { + print ''; + print ''; + print ''; +} else { + foreach ($backups as $bk) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } +} + +print '
'.$langs->trans("Filename").''.$langs->trans("Date").''.$langs->trans("Size").''.$langs->trans("Actions").'
'.$langs->trans("NoBackupsFound").'
'.dol_escape_htmltag($bk['filename']).''.dol_print_date(strtotime($bk['date']), 'dayhour').''.dol_print_size($bk['size']).''; + + // Download button + print ''; + print ''; + print ' '; + + // Restore button + print ''; + print ''; + print ' '; + + // Delete button + print ''; + print ''; + print ''; + + print '
'; + +// Info section +print '
'; +print '
'; +print ' '.$langs->trans("BackupInfo").':
'; +print '• '.$langs->trans("BackupInfoContent").'
'; +print '• '.$langs->trans("BackupInfoFiles").'
'; +print '• '.$langs->trans("BackupInfoRestore").'
'; +print '
'; + +print '
'; + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/admin/building_types.php b/admin/building_types.php new file mode 100755 index 0000000..85939af --- /dev/null +++ b/admin/building_types.php @@ -0,0 +1,351 @@ +loadLangs(array('admin', 'kundenkarte@kundenkarte')); + +// Security check +if (!$user->admin) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$id = GETPOSTINT('id'); +$levelFilter = GETPOST('level_filter', 'alpha'); + +$buildingType = new BuildingType($db); +$error = 0; + +// Actions +if ($action == 'add' && $user->admin) { + $buildingType->ref = GETPOST('ref', 'alphanohtml'); + $buildingType->label = GETPOST('label', 'alphanohtml'); + $buildingType->label_short = GETPOST('label_short', 'alphanohtml'); + $buildingType->description = GETPOST('description', 'restricthtml'); + $buildingType->fk_parent = GETPOSTINT('fk_parent'); + $buildingType->level_type = GETPOST('level_type', 'alpha'); + $buildingType->icon = GETPOST('icon', 'alphanohtml'); + $buildingType->color = GETPOST('color', 'alphanohtml'); + $buildingType->can_have_children = GETPOSTINT('can_have_children'); + $buildingType->position = GETPOSTINT('position'); + $buildingType->active = GETPOSTINT('active'); + + if (empty($buildingType->ref)) { + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentitiesaliases('Ref')), null, 'errors'); + $error++; + } + if (empty($buildingType->label)) { + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentitiesaliases('Label')), null, 'errors'); + $error++; + } + + if (!$error) { + $result = $buildingType->create($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordCreatedSuccessfully'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } else { + setEventMessages($buildingType->error, $buildingType->errors, 'errors'); + } + } + $action = 'create'; +} + +if ($action == 'update' && $user->admin) { + $result = $buildingType->fetch($id); + if ($result > 0) { + // Don't allow editing ref of system types + if (!$buildingType->is_system) { + $buildingType->ref = GETPOST('ref', 'alphanohtml'); + } + $buildingType->label = GETPOST('label', 'alphanohtml'); + $buildingType->label_short = GETPOST('label_short', 'alphanohtml'); + $buildingType->description = GETPOST('description', 'restricthtml'); + $buildingType->fk_parent = GETPOSTINT('fk_parent'); + $buildingType->level_type = GETPOST('level_type', 'alpha'); + $buildingType->icon = GETPOST('icon', 'alphanohtml'); + $buildingType->color = GETPOST('color', 'alphanohtml'); + $buildingType->can_have_children = GETPOSTINT('can_have_children'); + $buildingType->position = GETPOSTINT('position'); + $buildingType->active = GETPOSTINT('active'); + + $result = $buildingType->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordModifiedSuccessfully'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } else { + setEventMessages($buildingType->error, $buildingType->errors, 'errors'); + } + } +} + +if ($action == 'confirm_delete' && $confirm == 'yes' && $user->admin) { + $result = $buildingType->fetch($id); + if ($result > 0) { + $result = $buildingType->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($langs->trans($buildingType->error), $buildingType->errors, 'errors'); + } + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +// Load data for edit +if (($action == 'edit' || $action == 'delete') && $id > 0) { + $result = $buildingType->fetch($id); +} + +/* + * View + */ + +$page_name = "BuildingTypesSetup"; +llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-kundenkarte page-admin-building_types'); + +$linkback = ''.$langs->trans("BackToModuleList").''; +print load_fiche_titre($langs->trans($page_name), $linkback, 'object_kundenkarte@kundenkarte'); + +print '
'; + +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'building_types', $langs->trans("Module500015Name"), -1, 'kundenkarte@kundenkarte'); + +// Delete confirmation +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?id='.$buildingType->id, + $langs->trans('DeleteBuildingType'), + $langs->trans('ConfirmDeleteBuildingType', $buildingType->label), + 'confirm_delete', + '', + 0, + 1 + ); +} + +// Level type filter +$levelTypes = BuildingType::getLevelTypes(); +print '
'; +print '
'; +print '
'; +print ''; +print ''; +print '
'; +print '
'; +print '
'; + +// Add/Edit form +if ($action == 'create' || $action == 'edit') { + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + // Ref + print ''; + + // Label + print ''; + + // Label Short + print ''; + + // Level Type + print ''; + + // Icon + print ''; + + // Color + print ''; + + // Can have children + print ''; + + // Position + print ''; + + // Active + print ''; + + // Description + print ''; + + print '
'.$langs->trans('Ref').''; + if ($action == 'edit' && $buildingType->is_system) { + print ''; + print $buildingType->ref.' ('.$langs->trans('SystemType').')'; + } else { + print ''; + } + print '
'.$langs->trans('Label').''; + print ''; + print '
'.$langs->trans('LabelShort').''; + print ''; + print '
'.$langs->trans('LevelType').''; + print ''; + print '
'.$langs->trans('Icon').''; + print ''; + if ($buildingType->icon) { + print ' '; + } + print ' (FontAwesome, z.B. fa-home, fa-building)'; + print '
'.$langs->trans('Color').''; + print ''; + print ' '; + print '
'.$langs->trans('CanHaveChildren').''; + print 'can_have_children || $action != 'edit' ? ' checked' : '').'>'; + print '
'.$langs->trans('Position').''; + $defaultPos = $action == 'create' ? $buildingType->getNextPosition() : $buildingType->position; + print ''; + print '
'.$langs->trans('Active').''; + print 'active || $action != 'edit' ? ' checked' : '').'>'; + print '
'.$langs->trans('Description').''; + print ''; + print '
'; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + + // Sync color inputs + print ''; + +} else { + // List of building types + print '
'; + print ''; + + // Header + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + // Fetch types + $types = $buildingType->fetchAll(0, $levelFilter); + + if (count($types) > 0) { + foreach ($types as $type) { + print ''; + + // Ref + print ''; + + // Label + print ''; + + // Level Type + print ''; + + // Icon + print ''; + + // Color + print ''; + + // Position + print ''; + + // Active + print ''; + + // Actions + print ''; + + print ''; + } + } else { + print ''; + } + + print '
'.$langs->trans('Ref').''.$langs->trans('Label').''.$langs->trans('LevelType').''.$langs->trans('Icon').''.$langs->trans('Color').''.$langs->trans('Position').''.$langs->trans('Active').''.$langs->trans('Actions').'
'.$type->ref; + if ($type->is_system) { + print ' '.$langs->trans('System').''; + } + print ''; + if ($type->icon) { + print ' '; + } + print dol_escape_htmltag($type->label); + if ($type->label_short) { + print ' ('.$type->label_short.')'; + } + print ''.$type->getLevelTypeLabel().''; + if ($type->icon) { + print ' '.$type->icon; + } + print ''; + if ($type->color) { + print ' '.$type->color; + } + print ''.$type->position.''; + print $type->active ? ''.$langs->trans('Yes').'' : ''.$langs->trans('No').''; + print ''; + print ''.img_edit().''; + if (!$type->is_system) { + print ' '.img_delete().''; + } + print '
'.$langs->trans('NoRecordFound').'
'; + print '
'; + + // Add button + print '
'; + print ''.$langs->trans('AddBuildingType').''; + print '
'; +} + +print dol_get_fiche_end(); +print '
'; + +llxFooter(); +$db->close(); diff --git a/admin/busbar_types.php b/admin/busbar_types.php new file mode 100755 index 0000000..d44d80e --- /dev/null +++ b/admin/busbar_types.php @@ -0,0 +1,460 @@ +loadLangs(array('admin', 'kundenkarte@kundenkarte', 'products')); + +// Security check +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$typeId = GETPOSTINT('typeid'); + +// System filter - save in session for persistence +$sessionKey = 'kundenkarte_busbar_types_system_filter'; +if (GETPOSTISSET('system')) { + $systemFilter = GETPOSTINT('system'); + $_SESSION[$sessionKey] = $systemFilter; +} elseif (isset($_SESSION[$sessionKey])) { + $systemFilter = $_SESSION[$sessionKey]; +} else { + $systemFilter = 0; +} + +$form = new Form($db); +$busbarType = new BusbarType($db); + +// Load systems +$systems = array(); +$sql = "SELECT rowid, code, label FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $systems[$obj->rowid] = $obj; + } +} + +// Load products for dropdown +$products = array(); +$sql = "SELECT rowid, ref, label FROM ".MAIN_DB_PREFIX."product WHERE tosell = 1 ORDER BY ref ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $products[$obj->rowid] = $obj; + } +} + +// Predefined channel configurations (system-independent) +$channelPresets = array( + // General presets + 'A' => array('label' => 'Kanal A (1 Linie)', 'num_lines' => 1, 'colors' => '#e74c3c'), + 'B' => array('label' => 'Kanal B (1 Linie)', 'num_lines' => 1, 'colors' => '#2ecc71'), + 'C' => array('label' => 'Kanal C (1 Linie)', 'num_lines' => 1, 'colors' => '#9b59b6'), + 'AB' => array('label' => 'Kanal A+B (2 Linien)', 'num_lines' => 2, 'colors' => '#e74c3c,#2ecc71'), + 'ABC' => array('label' => 'Kanal A+B+C (3 Linien)', 'num_lines' => 3, 'colors' => '#e74c3c,#2ecc71,#9b59b6'), + '4CH' => array('label' => '4 Kanaele', 'num_lines' => 4, 'colors' => '#e74c3c,#2ecc71,#9b59b6,#3498db'), + '5CH' => array('label' => '5 Kanaele', 'num_lines' => 5, 'colors' => '#e74c3c,#2ecc71,#9b59b6,#3498db,#f1c40f'), + // Electrical presets (for backward compatibility) + 'L1' => array('label' => 'L1 (Strom)', 'num_lines' => 1, 'colors' => '#e74c3c'), + '3P+N' => array('label' => '3P+N (Strom)', 'num_lines' => 4, 'colors' => '#e74c3c,#2ecc71,#9b59b6,#3498db'), +); + +/* + * Actions + */ + +if ($action == 'add') { + $busbarType->ref = GETPOST('ref', 'aZ09'); + $busbarType->label = GETPOST('label', 'alphanohtml'); + $busbarType->label_short = GETPOST('label_short', 'alphanohtml'); + $busbarType->description = GETPOST('description', 'restricthtml'); + $busbarType->fk_system = GETPOSTINT('fk_system'); + $busbarType->phases = GETPOST('phases', 'alphanohtml'); + $busbarType->num_lines = GETPOSTINT('num_lines'); + $busbarType->color = GETPOST('color', 'alphanohtml'); + $busbarType->default_color = GETPOST('default_color', 'alphanohtml'); + $busbarType->line_height = GETPOSTINT('line_height') ?: 3; + $busbarType->line_spacing = GETPOSTINT('line_spacing') ?: 4; + $busbarType->position_default = GETPOST('position_default', 'alphanohtml') ?: 'below'; + $busbarType->fk_product = GETPOSTINT('fk_product'); + $busbarType->picto = GETPOST('picto', 'alphanohtml'); + $busbarType->position = GETPOSTINT('position'); + $busbarType->active = 1; + + if (empty($busbarType->ref) || empty($busbarType->label) || empty($busbarType->phases)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + $action = 'create'; + } else { + $result = $busbarType->create($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?system='.$busbarType->fk_system); + exit; + } else { + setEventMessages($busbarType->error, $busbarType->errors, 'errors'); + $action = 'create'; + } + } +} + +if ($action == 'update') { + $busbarType->fetch($typeId); + $busbarType->ref = GETPOST('ref', 'aZ09'); + $busbarType->label = GETPOST('label', 'alphanohtml'); + $busbarType->label_short = GETPOST('label_short', 'alphanohtml'); + $busbarType->description = GETPOST('description', 'restricthtml'); + $busbarType->fk_system = GETPOSTINT('fk_system'); + $busbarType->phases = GETPOST('phases', 'alphanohtml'); + $busbarType->num_lines = GETPOSTINT('num_lines'); + $busbarType->color = GETPOST('color', 'alphanohtml'); + $busbarType->default_color = GETPOST('default_color', 'alphanohtml'); + $busbarType->line_height = GETPOSTINT('line_height') ?: 3; + $busbarType->line_spacing = GETPOSTINT('line_spacing') ?: 4; + $busbarType->position_default = GETPOST('position_default', 'alphanohtml') ?: 'below'; + $busbarType->fk_product = GETPOSTINT('fk_product'); + $busbarType->picto = GETPOST('picto', 'alphanohtml'); + $busbarType->position = GETPOSTINT('position'); + + $result = $busbarType->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?system='.$busbarType->fk_system); + exit; + } else { + setEventMessages($busbarType->error, $busbarType->errors, 'errors'); + $action = 'edit'; + } +} + +if ($action == 'confirm_delete' && $confirm == 'yes') { + $busbarType->fetch($typeId); + $result = $busbarType->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($busbarType->error, $busbarType->errors, 'errors'); + } + $action = ''; +} + +if ($action == 'activate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_busbar_type SET active = 1 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +if ($action == 'deactivate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_busbar_type SET active = 0 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +/* + * View + */ + +llxHeader('', $langs->trans('BusbarTypes')); + +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'busbar_types', $langs->trans('KundenkarteSetup'), -1, 'kundenkarte@kundenkarte'); + +// System filter +print '
'; +print '
'; +print $langs->trans('System').': '; +print ''; +print ' '; +print ' '.$langs->trans('NewBusbarType').''; +print '
'; +print '
'; + +// Delete confirmation +if ($action == 'delete') { + $busbarType->fetch($typeId); + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId.'&system='.$systemFilter, + $langs->trans('DeleteBusbarType'), + $langs->trans('ConfirmDeleteBusbarType', $busbarType->label), + 'confirm_delete', + '', + 0, + 1 + ); +} + +// Create/Edit form +if ($action == 'create' || $action == 'edit') { + if ($action == 'edit') { + $busbarType->fetch($typeId); + } + + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + // Ref + print ''; + print ''; + + // Label + print ''; + print ''; + + // Label Short + print ''; + print ''; + + // Description + print ''; + print ''; + + // System + print ''; + print ''; + + // Channel configuration + print ''; + print ''; + + // Number of lines + print ''; + print ''; + + // Colors + print ''; + print ''; + + // Default color + print ''; + print ''; + + // Line height + print ''; + print ''; + + // Line spacing + print ''; + print ''; + + // Default position + print ''; + print ''; + + // Product link + print ''; + print ''; + + // Position + print ''; + print ''; + + // Preview + print ''; + print ''; + + print '
'.$langs->trans('Ref').'
'.$langs->trans('Label').'
'.$langs->trans('LabelShort').'
'.$langs->trans('Description').'
'.$langs->trans('System').''; + print '
'.$langs->trans('AllSystemsHint').'
'; + print '
'.$langs->trans('Channels').''; + print '
'; + print ''.$langs->trans('QuickSelect').':
'; + foreach ($channelPresets as $code => $preset) { + $style = 'display:inline-block;margin:3px;padding:5px 10px;border:1px solid #ccc;border-radius:4px;cursor:pointer;background:#f8f8f8;'; + print ''; + print dol_escape_htmltag($preset['label']); + print ''; + } + print '
'; + print ''; + print '
'.$langs->trans('ChannelsHint').'
'; + print '
'.$langs->trans('NumLines').'
'.$langs->trans('Colors').''; + print ''; + print '
Kommagetrennte Farbcodes fuer jede Linie (z.B. #e74c3c,#2ecc71,#9b59b6)
'; + print '
'.$langs->trans('DefaultColor').'
'.$langs->trans('LineHeight').' px
'.$langs->trans('LineSpacing').' px
'.$langs->trans('DefaultPosition').'
'.$langs->trans('LinkedProduct').'
'.$langs->trans('Position').'
'.$langs->trans('Preview').''; + print '
'; + print ''; + print '
'; + print '
'; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + + // JavaScript for preset selection and preview + print ''; + +} else { + // List of busbar types + $types = $busbarType->fetchAllBySystem($systemFilter, 0); + + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + if (empty($types)) { + print ''; + } else { + foreach ($types as $type) { + print ''; + print ''; + print ''; + print ''; + print ''; + + // Color preview + print ''; + + print ''; + print ''; + + // Status + print ''; + + // Actions + print ''; + print ''; + } + } + + print '
'.$langs->trans('Ref').''.$langs->trans('Label').''.$langs->trans('Channels').''.$langs->trans('Lines').''.$langs->trans('Colors').''.$langs->trans('System').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
'.$langs->trans('NoRecordFound').'
'.dol_escape_htmltag($type->ref).''.dol_escape_htmltag($type->label); + if ($type->label_short) { + print ' ('.dol_escape_htmltag($type->label_short).')'; + } + print ''.dol_escape_htmltag($type->phases).''.$type->num_lines.''; + $colors = $type->color ? explode(',', $type->color) : array($type->default_color ?: '#e74c3c'); + foreach ($colors as $c) { + print ''; + } + print ''; + if (empty($type->fk_system)) { + print ''.$langs->trans('AllSystems').''; + } else { + print dol_escape_htmltag($type->system_label); + } + print ''.$type->position.''; + if ($type->active) { + print ''.$langs->trans('Enabled').''; + } else { + print ''.$langs->trans('Disabled').''; + } + print ''; + print ''; + print img_edit(); + print ' '; + + if ($type->active) { + print ''; + print img_picto($langs->trans('Disable'), 'switch_on'); + print ' '; + } else { + print ''; + print img_picto($langs->trans('Enable'), 'switch_off'); + print ' '; + } + + if (!$type->is_system) { + print ''; + print img_delete(); + print ''; + } + print '
'; + print '
'; +} + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/admin/equipment_types.php b/admin/equipment_types.php new file mode 100755 index 0000000..22ee53e --- /dev/null +++ b/admin/equipment_types.php @@ -0,0 +1,984 @@ +loadLangs(array('admin', 'kundenkarte@kundenkarte', 'products')); + +// Security check +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$typeId = GETPOSTINT('typeid'); + +// System filter - save in session for persistence +$sessionKey = 'kundenkarte_equipment_types_system_filter'; +if (GETPOSTISSET('system')) { + $systemFilter = GETPOSTINT('system'); + $_SESSION[$sessionKey] = $systemFilter; +} elseif (isset($_SESSION[$sessionKey])) { + $systemFilter = $_SESSION[$sessionKey]; +} else { + $systemFilter = 0; +} + +$form = new Form($db); +$equipmentType = new EquipmentType($db); + +// Load systems +$systems = array(); +$sql = "SELECT rowid, code, label FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $systems[$obj->rowid] = $obj; + } +} + +// Load products for dropdown +$products = array(); +$sql = "SELECT rowid, ref, label FROM ".MAIN_DB_PREFIX."product WHERE tosell = 1 ORDER BY ref ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $products[$obj->rowid] = $obj; + } +} + +/* + * Actions + */ + +if ($action == 'add') { + $equipmentType->ref = GETPOST('ref', 'aZ09'); + $equipmentType->label = GETPOST('label', 'alphanohtml'); + $equipmentType->label_short = GETPOST('label_short', 'alphanohtml'); + $equipmentType->description = GETPOST('description', 'restricthtml'); + $equipmentType->fk_system = GETPOSTINT('fk_system'); + $equipmentType->width_te = GETPOSTINT('width_te'); + $equipmentType->color = GETPOST('color', 'alphanohtml'); + // fk_product removed - products are selected per equipment in editor + $equipmentType->terminals_config = GETPOST('terminals_config', 'nohtml'); + $equipmentType->flow_direction = GETPOST('flow_direction', 'alphanohtml'); + $equipmentType->terminal_position = GETPOST('terminal_position', 'alphanohtml') ?: 'both'; + $equipmentType->picto = GETPOST('picto', 'alphanohtml'); + $equipmentType->position = GETPOSTINT('position'); + $equipmentType->active = 1; + + if (empty($equipmentType->ref) || empty($equipmentType->label) || empty($equipmentType->fk_system)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + $action = 'create'; + } else { + $result = $equipmentType->create($user); + if ($result > 0) { + // Create default fields for common equipment + $defaultFields = array( + array('code' => 'characteristic', 'label' => 'Charakteristik', 'type' => 'select', 'options' => 'B|C|D|K|Z', 'position' => 10, 'show_in_hover' => 1, 'show_on_block' => 1), + array('code' => 'ampere', 'label' => 'Nennstrom (A)', 'type' => 'select', 'options' => '6|10|13|16|20|25|32|40|50|63', 'position' => 20, 'show_in_hover' => 1, 'show_on_block' => 1), + array('code' => 'pole', 'label' => 'Polzahl', 'type' => 'select', 'options' => '1|2|3|3+N', 'position' => 30, 'show_in_hover' => 1, 'show_on_block' => 0), + array('code' => 'circuit', 'label' => 'Stromkreis', 'type' => 'text', 'options' => '', 'position' => 40, 'show_in_hover' => 1, 'show_on_block' => 0), + ); + + foreach ($defaultFields as $field) { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " (fk_equipment_type, field_code, field_label, field_type, field_options, show_in_hover, show_on_block, required, position, active)"; + $sql .= " VALUES (".((int) $equipmentType->id).", '".$db->escape($field['code'])."', '".$db->escape($field['label'])."',"; + $sql .= " '".$db->escape($field['type'])."', '".$db->escape($field['options'])."', ".((int) $field['show_in_hover']).", ".((int) $field['show_on_block']).", 0, ".((int) $field['position']).", 1)"; + $db->query($sql); + } + + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$equipmentType->id.'&system='.$equipmentType->fk_system); + exit; + } else { + setEventMessages($equipmentType->error, $equipmentType->errors, 'errors'); + $action = 'create'; + } + } +} + +if ($action == 'update') { + $equipmentType->fetch($typeId); + $equipmentType->ref = GETPOST('ref', 'aZ09'); + $equipmentType->label = GETPOST('label', 'alphanohtml'); + $equipmentType->label_short = GETPOST('label_short', 'alphanohtml'); + $equipmentType->description = GETPOST('description', 'restricthtml'); + $equipmentType->fk_system = GETPOSTINT('fk_system'); + $equipmentType->width_te = GETPOSTINT('width_te'); + $equipmentType->color = GETPOST('color', 'alphanohtml'); + // fk_product removed - products are selected per equipment in editor + $equipmentType->terminals_config = GETPOST('terminals_config', 'nohtml'); + $equipmentType->flow_direction = GETPOST('flow_direction', 'alphanohtml'); + $equipmentType->terminal_position = GETPOST('terminal_position', 'alphanohtml') ?: 'both'; + $equipmentType->picto = GETPOST('picto', 'alphanohtml'); + $equipmentType->position = GETPOSTINT('position'); + + $result = $equipmentType->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?system='.$equipmentType->fk_system); + exit; + } else { + setEventMessages($equipmentType->error, $equipmentType->errors, 'errors'); + $action = 'edit'; + } +} + +if ($action == 'confirm_delete' && $confirm == 'yes') { + $equipmentType->fetch($typeId); + $result = $equipmentType->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($equipmentType->error, $equipmentType->errors, 'errors'); + } + $action = ''; +} + +if ($action == 'activate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_equipment_type SET active = 1 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +if ($action == 'deactivate') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_equipment_type SET active = 0 WHERE rowid = ".((int) $typeId); + $db->query($sql); + $action = ''; +} + +// Copy type with all fields +if ($action == 'copy' && $typeId > 0) { + $sourceType = new EquipmentType($db); + if ($sourceType->fetch($typeId) > 0) { + $newType = new EquipmentType($db); + $newType->ref = $sourceType->ref.'_COPY'; + $newType->label = $sourceType->label.' (Kopie)'; + $newType->label_short = $sourceType->label_short; + $newType->description = $sourceType->description; + $newType->fk_system = $sourceType->fk_system; + $newType->width_te = $sourceType->width_te; + $newType->color = $sourceType->color; + // fk_product not copied - products are selected per equipment in editor + $newType->picto = $sourceType->picto; + $newType->position = $sourceType->position + 1; + $newType->active = 1; + + $result = $newType->create($user); + if ($result > 0) { + // Copy all fields from source type + $sourceFields = $sourceType->fetchFields(0); + foreach ($sourceFields as $field) { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " (fk_equipment_type, field_code, field_label, field_type, field_options, show_in_hover, show_on_block, required, position, active)"; + $sql .= " VALUES (".((int) $newType->id).", '".$db->escape($field->field_code)."', '".$db->escape($field->field_label)."',"; + $sql .= " '".$db->escape($field->field_type)."', '".$db->escape($field->field_options)."', ".((int) $field->show_in_hover).","; + $sql .= " ".((int) $field->show_on_block).", ".((int) $field->required).", ".((int) $field->position).", ".((int) $field->active).")"; + $db->query($sql); + } + + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$newType->id.'&system='.$newType->fk_system); + exit; + } else { + setEventMessages($newType->error, $newType->errors, 'errors'); + } + } + $action = ''; +} + +// Field actions +$fieldId = GETPOSTINT('fieldid'); + +if ($action == 'add_field') { + $fieldCode = GETPOST('field_code', 'aZ09'); + $fieldLabel = GETPOST('field_label', 'alphanohtml'); + $fieldType = GETPOST('field_type', 'aZ09'); + $fieldOptions = GETPOST('field_options', 'nohtml'); + $showInHover = GETPOSTINT('show_in_hover'); + $showOnBlock = GETPOSTINT('show_on_block'); + $isRequired = GETPOSTINT('is_required'); + $fieldPosition = GETPOSTINT('field_position'); + + if (empty($fieldCode) || empty($fieldLabel) || empty($fieldType)) { + setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors'); + } else { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " (fk_equipment_type, field_code, field_label, field_type, field_options, show_in_hover, show_on_block, required, position, active)"; + $sql .= " VALUES (".((int) $typeId).", '".$db->escape($fieldCode)."', '".$db->escape($fieldLabel)."',"; + $sql .= " '".$db->escape($fieldType)."', '".$db->escape($fieldOptions)."',"; + $sql .= " ".((int) $showInHover).", ".((int) $showOnBlock).", ".((int) $isRequired).", ".((int) $fieldPosition).", 1)"; + + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + } + $action = 'edit'; +} + +if ($action == 'update_field') { + $fieldCode = GETPOST('field_code', 'aZ09'); + $fieldLabel = GETPOST('field_label', 'alphanohtml'); + $fieldType = GETPOST('field_type', 'aZ09'); + $fieldOptions = GETPOST('field_options', 'nohtml'); + $showInHover = GETPOSTINT('show_in_hover'); + $showOnBlock = GETPOSTINT('show_on_block'); + $isRequired = GETPOSTINT('is_required'); + $fieldPosition = GETPOSTINT('field_position'); + + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field SET"; + $sql .= " field_code = '".$db->escape($fieldCode)."',"; + $sql .= " field_label = '".$db->escape($fieldLabel)."',"; + $sql .= " field_type = '".$db->escape($fieldType)."',"; + $sql .= " field_options = '".$db->escape($fieldOptions)."',"; + $sql .= " show_in_hover = ".((int) $showInHover).","; + $sql .= " show_on_block = ".((int) $showOnBlock).","; + $sql .= " required = ".((int) $isRequired).","; + $sql .= " position = ".((int) $fieldPosition); + $sql .= " WHERE rowid = ".((int) $fieldId); + + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + $action = 'edit'; +} + +if ($action == 'confirm_delete_field' && $confirm == 'yes') { + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field WHERE rowid = ".((int) $fieldId); + if ($db->query($sql)) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($db->lasterror(), null, 'errors'); + } + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$typeId.'&system='.$systemFilter); + exit; +} + +if ($action == 'activate_field') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field SET active = 1 WHERE rowid = ".((int) $fieldId); + $db->query($sql); + $action = 'edit'; +} + +if ($action == 'deactivate_field') { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field SET active = 0 WHERE rowid = ".((int) $fieldId); + $db->query($sql); + $action = 'edit'; +} + +/* + * View + */ + +$title = $langs->trans('EquipmentTypes'); + +$morejs = array('/kundenkarte/js/kundenkarte.js?v='.time()); +$morecss = array('/kundenkarte/css/kundenkarte.css?v='.time()); + +llxHeader('', $title, '', '', 0, 0, $morejs, $morecss); + +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'equipment_types', $langs->trans('ModuleKundenKarteName'), -1, 'fa-file'); + +// Confirmation for type deletion +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId.'&system='.$systemFilter, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteType'), + 'confirm_delete', + '', + 'yes', + 1 + ); +} + +// Confirmation for field deletion +if ($action == 'delete_field') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId.'&fieldid='.$fieldId.'&system='.$systemFilter, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteField'), + 'confirm_delete_field', + '', + 'yes', + 1 + ); + $action = 'edit'; +} + +// Add/Edit form +if (in_array($action, array('create', 'edit'))) { + if ($action == 'edit' && $typeId > 0) { + $equipmentType->fetch($typeId); + } + + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + // System + print ''; + print ''; + + // Reference + print ''; + print ''; + + // Label + print ''; + print ''; + + // Short label + print ''; + print ''; + + // Description + print ''; + print ''; + + // Width in TE + print ''; + print ''; + + // Color + print ''; + print ''; + + // Note: Product selection removed from type - products are now selected per equipment in the editor + // based on the actual configuration (type + characteristic + ampere) + + // FontAwesome Icon (fallback) + print ''; + print ''; + + // Schaltzeichen Upload (SVG/PNG) + print ''; + print ''; + + // Block Image Upload (for SchematicEditor display) + print ''; + print ''; + + // Position + print ''; + print ''; + + // Terminal configuration + print ''; + print ''; + + // Terminal Position + print ''; + print ''; + + // Flow Direction + print ''; + print ''; + + print '
'.$langs->trans('System').'
'.$langs->trans('TypeRef').''; + print ' (UPPERCASE, no spaces)
'.$langs->trans('TypeLabel').'
'.$langs->trans('TypeShortLabel').''; + print ' (z.B. LS, FI, FI/LS)
'.$langs->trans('Description').'
'.$langs->trans('WidthTE').''; + print ' '.$langs->trans('WidthTEHelp').'
'.$langs->trans('Color').''; + print ' '.$langs->trans('ColorForSVG').'
'.$langs->trans('SystemPicto').'
'; + print ''; + if ($equipmentType->picto) { + print kundenkarte_render_icon($equipmentType->picto); + } + print ''; + print ''; + print ''; + print '
'; + print 'Fallback-Icon wenn kein Schaltzeichen hochgeladen'; + print '
'.$langs->trans('SchematicSymbol').''; + print '
'; + + // Preview area + print '
'; + if ($action == 'edit' && $equipmentType->icon_file) { + $iconUrl = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=equipment_icons/'.urlencode($equipmentType->icon_file); + print 'Icon'; + } else { + print 'Kein
Symbol
'; + } + print '
'; + + // Upload controls + print '
'; + print ''; + print ''; + if ($action == 'edit' && $equipmentType->icon_file) { + print ' '; + } + print '
Normgerechte Symbole nach IEC 60617 / DIN EN 60617
'; + print '
'; + + print '
'; + print '
'.$langs->trans('BlockImage').''; + print '
'; + + // Preview area + print '
'; + if ($action == 'edit' && $equipmentType->block_image) { + $blockImageUrl = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.urlencode($equipmentType->block_image); + print 'Block Image'; + } else { + print 'Kein
Bild
'; + } + print '
'; + + // Upload controls + print '
'; + print ''; + print ''; + if ($action == 'edit' && $equipmentType->block_image) { + print ' '; + } + + // Dropdown to select existing images + $blockImagesDir = DOL_DATA_ROOT.'/kundenkarte/block_images/'; + $existingImages = array(); + if (is_dir($blockImagesDir)) { + $files = scandir($blockImagesDir); + foreach ($files as $file) { + if ($file != '.' && $file != '..' && preg_match('/\.(png|jpg|jpeg|gif|svg|webp)$/i', $file)) { + $existingImages[] = $file; + } + } + sort($existingImages); + } + + if (!empty($existingImages)) { + print '
'; + print ''; + print ' '; + print '
'; + } + + print '
Bild wird im SchematicEditor als Block-Hintergrund angezeigt
'; + print '
'; + + print '
'; + print '
'.$langs->trans('Position').'
'.$langs->trans('TerminalConfig').''; + print '
'; + print ''; + print ' '; + print ' '; + print ' '; + print ''; + print '
'; + print ''; + print '
'; + print 'JSON-Format: {"terminals":[{"id":"t1","label":"L","pos":"top"},...]} - pos: "top" oder "bottom"'; + print '
'; + print '
Anschlusspunkt-Position'; + print ''; + print '
Wo sollen die Anschlusspunkte angezeigt werden?
'; + print '
Richtung (Pfeil)'; + print ''; + print '
Zeigt einen Richtungspfeil im Block an (z.B. für Typ B FI-Schalter)
'; + print '
'; + + // JavaScript for terminal presets and icon upload + $typeIdJs = $action == 'edit' ? $typeId : 0; + print ''; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + + // Fields management for existing type + if ($action == 'edit' && $typeId > 0) { + $editFieldId = GETPOSTINT('editfield'); + + print '

'; + print '

'.$langs->trans('EquipmentTypeFields').'

'; + + $fields = $equipmentType->fetchFields(0); + + // Field types available + $fieldTypes = array( + 'text' => 'Textfeld (einzeilig)', + 'textarea' => 'Textfeld (mehrzeilig)', + 'number' => 'Zahlenfeld', + 'select' => 'Dropdown-Auswahl', + 'date' => 'Datumsfeld', + 'checkbox' => 'Checkbox (Ja/Nein)', + ); + + // Output edit forms BEFORE the table + foreach ($fields as $field) { + if ($editFieldId == $field->rowid) { + $formId = 'editfield_'.$field->rowid; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'; + } + } + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($fields as $field) { + if ($editFieldId == $field->rowid) { + $formId = 'editfield_'.$field->rowid; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } else { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } + + if (empty($fields)) { + print ''; + } + + print '
'.$langs->trans('FieldCode').''.$langs->trans('FieldLabel').''.$langs->trans('FieldType').''.$langs->trans('FieldOptions').''.$langs->trans('ShowInHover').''.$langs->trans('ShowOnBlock').''.$langs->trans('IsRequired').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
show_in_hover ? ' checked' : '').'>show_on_block ? ' checked' : '').'>required ? ' checked' : '').'>'; + print '
'; + print ''; + print ''; + print '
'; + print '
'.dol_escape_htmltag($field->field_code).''.dol_escape_htmltag($field->field_label).''.dol_escape_htmltag($fieldTypes[$field->field_type] ?? $field->field_type).''.dol_escape_htmltag(dol_trunc($field->field_options, 20)).''.($field->show_in_hover ? img_picto('', 'tick') : '').''.($field->show_on_block ? img_picto('', 'tick') : '').''.($field->required ? img_picto('', 'tick') : '').''.$field->position.''; + if ($field->active) { + print ''.img_picto($langs->trans('Enabled'), 'switch_on').''; + } else { + print ''.img_picto($langs->trans('Disabled'), 'switch_off').''; + } + print ''; + print '
'; + print ''; + print ''; + print '
'; + print '
'.$langs->trans('NoFieldsDefined').'
'; + + // Add new field form + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'.$langs->trans('Add').' '.$langs->trans('Field').'
'; + print '
'; + print '
'; + + // Help box + print '
'; + print '

Hilfe: Equipment-Felder

'; + print ''; + print ''; + print ''; + print ''; + print '
Show in HoverFeld wird im Tooltip angezeigt
Show on BlockFeld wird direkt auf dem SVG-Block angezeigt (z.B. "B16")
Dropdown-AuswahlOptionen mit | trennen, z.B.: B|C|D oder 6|10|16|20|25|32
'; + print '
'; + } + +} else { + // System filter + print '
'; + print $langs->trans('FilterBySystem').': '; + print ''; + print '
'; + + // Add button + print ''; + + // List + $types = $equipmentType->fetchAllBySystem($systemFilter, 0); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($types as $type) { + print ''; + + print ''; + + print ''; + + print ''; + print ''; + print ''; + print ''; + + print ''; + + print ''; + + print ''; + } + + if (empty($types)) { + print ''; + } + + print '
'.$langs->trans('TypeRef').''.$langs->trans('TypeLabel').''.$langs->trans('System').''.$langs->trans('WidthTE').''.$langs->trans('Color').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
'; + if ($type->picto) { + print kundenkarte_render_icon($type->picto).' '; + } + print dol_escape_htmltag($type->ref).''.dol_escape_htmltag($type->label); + if ($type->label_short) { + print ' ('.$type->label_short.')'; + } + print ''.dol_escape_htmltag($type->system_label).''.$type->width_te.' TE'.$type->position.''; + if ($type->active) { + print ''.img_picto($langs->trans('Enabled'), 'switch_on').''; + } else { + print ''.img_picto($langs->trans('Disabled'), 'switch_off').''; + } + print ''; + print '
'; + print ''; + print ''; + if (!$type->is_system) { + print ''; + } + print '
'; + print '
'.$langs->trans('NoRecords').'
'; +} + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/admin/medium_types.php b/admin/medium_types.php new file mode 100755 index 0000000..99cf839 --- /dev/null +++ b/admin/medium_types.php @@ -0,0 +1,332 @@ +loadLangs(array("admin", "kundenkarte@kundenkarte")); + +// Security check +if (!$user->admin) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$typeId = GETPOSTINT('typeid'); + +$mediumType = new MediumType($db); + +// Load systems for dropdown +$systems = array(); +$sql = "SELECT rowid, code, label FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position, label"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $systems[$obj->rowid] = $obj; + } +} + +$error = 0; +$message = ''; + +// Actions +if ($action == 'add' && $user->admin) { + $mediumType->ref = GETPOST('ref', 'alphanohtml'); + $mediumType->label = GETPOST('label', 'alphanohtml'); + $mediumType->label_short = GETPOST('label_short', 'alphanohtml'); + $mediumType->description = GETPOST('description', 'restricthtml'); + $mediumType->fk_system = GETPOSTINT('fk_system'); + $mediumType->category = GETPOST('category', 'alphanohtml'); + $mediumType->default_spec = GETPOST('default_spec', 'alphanohtml'); + $mediumType->color = GETPOST('color', 'alphanohtml'); + $mediumType->position = GETPOSTINT('position'); + $mediumType->active = GETPOSTINT('active'); + + // Available specs as JSON array + $specsText = GETPOST('available_specs', 'nohtml'); + if ($specsText) { + $specsArray = array_map('trim', explode(',', $specsText)); + $mediumType->available_specs = json_encode($specsArray); + } + + $result = $mediumType->create($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } else { + setEventMessages($mediumType->error, $mediumType->errors, 'errors'); + $action = 'create'; + } +} + +if ($action == 'update' && $user->admin) { + if ($mediumType->fetch($typeId) > 0) { + $mediumType->ref = GETPOST('ref', 'alphanohtml'); + $mediumType->label = GETPOST('label', 'alphanohtml'); + $mediumType->label_short = GETPOST('label_short', 'alphanohtml'); + $mediumType->description = GETPOST('description', 'restricthtml'); + $mediumType->fk_system = GETPOSTINT('fk_system'); + $mediumType->category = GETPOST('category', 'alphanohtml'); + $mediumType->default_spec = GETPOST('default_spec', 'alphanohtml'); + $mediumType->color = GETPOST('color', 'alphanohtml'); + $mediumType->position = GETPOSTINT('position'); + $mediumType->active = GETPOSTINT('active'); + + $specsText = GETPOST('available_specs', 'nohtml'); + if ($specsText) { + $specsArray = array_map('trim', explode(',', $specsText)); + $mediumType->available_specs = json_encode($specsArray); + } else { + $mediumType->available_specs = ''; + } + + $result = $mediumType->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } else { + setEventMessages($mediumType->error, $mediumType->errors, 'errors'); + $action = 'edit'; + } + } +} + +if ($action == 'confirm_delete' && GETPOST('confirm') == 'yes' && $user->admin) { + if ($mediumType->fetch($typeId) > 0) { + $result = $mediumType->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + } else { + setEventMessages($mediumType->error, $mediumType->errors, 'errors'); + } + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +/* + * View + */ + +$title = $langs->trans('MediumTypes'); +llxHeader('', $title); + +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($title, $linkback, 'title_setup'); + +// Admin tabs +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'medium_types', $langs->trans('KundenKarte'), -1, 'kundenkarte@kundenkarte'); + +// Delete confirmation +if ($action == 'delete') { + if ($mediumType->fetch($typeId) > 0) { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?typeid='.$typeId, + $langs->trans('DeleteMediumType'), + $langs->trans('ConfirmDeleteMediumType', $mediumType->label), + 'confirm_delete', + '', + 0, + 1 + ); + } +} + +// Add/Edit form +if (in_array($action, array('create', 'edit'))) { + if ($action == 'edit' && $typeId > 0) { + $mediumType->fetch($typeId); + } + + print '
'; + print ''; + print ''; + if ($action == 'edit') { + print ''; + } + + print ''; + + // Ref + print ''; + print ''; + + // Label + print ''; + print ''; + + // Label short + print ''; + print ''; + + // System + print ''; + print ''; + + // Category + print ''; + print ''; + + // Default spec + print ''; + print ''; + + // Available specs + $specsText = ''; + if ($mediumType->available_specs) { + $specsArray = json_decode($mediumType->available_specs, true); + if (is_array($specsArray)) { + $specsText = implode(', ', $specsArray); + } + } + print ''; + print ''; + + // Color + print ''; + print ''; + + // Position + print ''; + print ''; + + // Status + print ''; + print ''; + + // Description + print ''; + print ''; + + print '
'.$langs->trans('Ref').'
'.$langs->trans('Label').'
'.$langs->trans('LabelShort').'
'.$langs->trans('System').'
'.$langs->trans('Category').'
'.$langs->trans('DefaultSpec').''; + print '
'.$langs->trans('DefaultSpecHelp').'
'.$langs->trans('AvailableSpecs').''; + print '
'.$langs->trans('AvailableSpecsHelp').'
'.$langs->trans('Color').''; + print '
'.$langs->trans('Position').'
'.$langs->trans('Status').'
'.$langs->trans('Description').'
'; + + print '
'; + print ''; + print ' '.$langs->trans('Cancel').''; + print '
'; + + print '
'; + +} else { + // List view + + // Button to add + print '
'; + print ''.$langs->trans('AddMediumType').''; + print '
'; + + // Filter by category + $filterCategory = GETPOST('filter_category', 'alphanohtml'); + print '
'; + print ''; + print ''; + print '
'; + + // List + $allTypes = $mediumType->fetchAllBySystem(0, 0); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + if (empty($allTypes)) { + print ''; + } else { + $i = 0; + foreach ($allTypes as $t) { + // Filter + if ($filterCategory && $t->category != $filterCategory) continue; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + $i++; + } + } + + print '
'.$langs->trans('Ref').''.$langs->trans('Label').''.$langs->trans('Category').''.$langs->trans('System').''.$langs->trans('DefaultSpec').''.$langs->trans('Color').''.$langs->trans('Position').''.$langs->trans('Status').''.$langs->trans('Actions').'
'.$langs->trans('NoRecords').'
'.dol_escape_htmltag($t->ref).''.dol_escape_htmltag($t->label); + if ($t->label_short) print ' ('.dol_escape_htmltag($t->label_short).')'; + print ''.dol_escape_htmltag($t->getCategoryLabel()).''; + if ($t->fk_system > 0 && $t->system_label) { + print dol_escape_htmltag($t->system_label); + } else { + print ''.$langs->trans('AllSystems').''; + } + print ''.dol_escape_htmltag($t->default_spec).''.$t->position.''; + print $t->active ? ''.$langs->trans('Enabled').'' : ''.$langs->trans('Disabled').''; + print ''; + print ''; + if (!$t->is_system) { + print ' '; + } + print '
'; +} + +print dol_get_fiche_end(); + +// JavaScript for color picker sync +print ''; + +llxFooter(); +$db->close(); diff --git a/admin/setup.php b/admin/setup.php new file mode 100755 index 0000000..821bb65 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,412 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file kundenkarte/admin/setup.php + * \ingroup kundenkarte + * \brief KundenKarte setup page. + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +require_once '../lib/kundenkarte.lib.php'; + +// Translations +$langs->loadLangs(array("admin", "kundenkarte@kundenkarte")); + +// Access control +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); + +// Directory for PDF templates +$uploadDir = $conf->kundenkarte->dir_output.'/templates'; + +/* + * Actions + */ + +// Handle PDF template upload +if ($action == 'upload_template') { + $error = 0; + + if (!empty($_FILES['pdf_template']['name'])) { + // Check file type + $fileInfo = pathinfo($_FILES['pdf_template']['name']); + if (strtolower($fileInfo['extension']) !== 'pdf') { + setEventMessages($langs->trans("ErrorOnlyPDFAllowed"), null, 'errors'); + $error++; + } + + if (!$error) { + // Create directory if not exists + if (!is_dir($uploadDir)) { + dol_mkdir($uploadDir); + } + + // Save template as fixed name + $targetFile = $uploadDir.'/export_template.pdf'; + + if (move_uploaded_file($_FILES['pdf_template']['tmp_name'], $targetFile)) { + dolibarr_set_const($db, 'KUNDENKARTE_PDF_TEMPLATE', 'export_template.pdf', 'chaine', 0, '', $conf->entity); + setEventMessages($langs->trans("TemplateUploadSuccess"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("ErrorUploadFailed"), null, 'errors'); + } + } + } else { + setEventMessages($langs->trans("ErrorNoFileSelected"), null, 'errors'); + } +} + +// Handle template deletion +if ($action == 'delete_template') { + $templateFile = $uploadDir.'/export_template.pdf'; + if (file_exists($templateFile)) { + unlink($templateFile); + dolibarr_set_const($db, 'KUNDENKARTE_PDF_TEMPLATE', '', 'chaine', 0, '', $conf->entity); + setEventMessages($langs->trans("TemplateDeleted"), null, 'mesgs'); + } +} + +if ($action == 'update') { + $error = 0; + + // Save settings + $res = dolibarr_set_const($db, 'KUNDENKARTE_SHOW_FAVORITES_TAB', GETPOSTINT('KUNDENKARTE_SHOW_FAVORITES_TAB'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + $res = dolibarr_set_const($db, 'KUNDENKARTE_SHOW_ANLAGEN_TAB', GETPOSTINT('KUNDENKARTE_SHOW_ANLAGEN_TAB'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + $res = dolibarr_set_const($db, 'KUNDENKARTE_DEFAULT_ORDER_TYPE', GETPOSTINT('KUNDENKARTE_DEFAULT_ORDER_TYPE'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + // PDF font size settings + dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_HEADER', GETPOSTINT('KUNDENKARTE_PDF_FONT_HEADER'), 'chaine', 0, '', $conf->entity); + dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_CONTENT', GETPOSTINT('KUNDENKARTE_PDF_FONT_CONTENT'), 'chaine', 0, '', $conf->entity); + dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_FIELDS', GETPOSTINT('KUNDENKARTE_PDF_FONT_FIELDS'), 'chaine', 0, '', $conf->entity); + + // View mode + dolibarr_set_const($db, 'KUNDENKARTE_DEFAULT_VIEW', GETPOST('KUNDENKARTE_DEFAULT_VIEW', 'aZ09'), 'chaine', 0, '', $conf->entity); + + // Tree display settings + dolibarr_set_const($db, 'KUNDENKARTE_TREE_INFO_DISPLAY', GETPOST('KUNDENKARTE_TREE_INFO_DISPLAY', 'aZ09'), 'chaine', 0, '', $conf->entity); + dolibarr_set_const($db, 'KUNDENKARTE_TREE_BADGE_COLOR', GETPOST('KUNDENKARTE_TREE_BADGE_COLOR', 'alphanohtml'), 'chaine', 0, '', $conf->entity); + + if (!$error) { + setEventMessages($langs->trans("SetupSaved"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("Error"), null, 'errors'); + } +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans("KundenKarteSetup"); +llxHeader('', $title); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; +print load_fiche_titre($title, $linkback, 'title_setup'); + +// Configuration header +$head = kundenkarteAdminPrepareHead(); +print dol_get_fiche_head($head, 'settings', $langs->trans('ModuleKundenKarteName'), -1, "fa-address-card"); + +print ''.$langs->trans("KundenKarteSetupPage").'

'; + +print '
'; +print ''; +print ''; + +print ''; + +// Header +print ''; +print ''; +print ''; +print ''; + +// Show Favorites Tab +print ''; +print ''; +print ''; +print ''; + +// Show Anlagen Tab +print ''; +print ''; +print ''; +print ''; + +// Default Order Type for Favorites +print ''; +print ''; +print ''; +print ''; + +// Default View Mode for Anlagen +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("Parameter").''.$langs->trans("Value").'
'.$langs->trans("ShowFavoritesTab").''; +print $form->selectyesno('KUNDENKARTE_SHOW_FAVORITES_TAB', getDolGlobalInt('KUNDENKARTE_SHOW_FAVORITES_TAB', 1), 1); +print '
'.$langs->trans("ShowAnlagenTab").''; +print $form->selectyesno('KUNDENKARTE_SHOW_ANLAGEN_TAB', getDolGlobalInt('KUNDENKARTE_SHOW_ANLAGEN_TAB', 1), 1); +print '
'.$langs->trans("DefaultOrderType").''; +$orderTypes = array( + 0 => $langs->trans("OrderTypeOrder"), + 1 => $langs->trans("OrderTypeProposal"), +); +print $form->selectarray('KUNDENKARTE_DEFAULT_ORDER_TYPE', $orderTypes, getDolGlobalInt('KUNDENKARTE_DEFAULT_ORDER_TYPE', 0)); +print '
'.$langs->trans("DefaultViewMode").''; +$viewModes = array( + 'tree' => $langs->trans("ViewModeTree"), + 'graph' => $langs->trans("ViewModeGraph"), +); +print $form->selectarray('KUNDENKARTE_DEFAULT_VIEW', $viewModes, getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree')); +print '
'; + +// Tree Display Settings +print '

'; +print '
'.$langs->trans("TreeDisplaySettings").'
'; +print '

'; + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Tree info display mode +$currentDisplay = getDolGlobalString('KUNDENKARTE_TREE_INFO_DISPLAY', 'badge'); +print ''; +print ''; +print ''; +print ''; +print ''; + +// Badge color +$currentColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e'); +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("Parameter").''.$langs->trans("Value").''.$langs->trans("Preview").'
'.$langs->trans("TreeInfoDisplayMode").''; +print ''; +print ''; +print ' Serverraum'; +print '(Standort: Serverraum)'; +print ''.$langs->trans("Hidden").''; +print '
'; + +print ''; + +// PDF Font Size Settings +print '

'; +print '
'.$langs->trans("PDFFontSettings").'
'; +print '

'; + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Header font size +print ''; +print ''; +print ''; +print ''; +print ''; + +// Content font size +print ''; +print ''; +print ''; +print ''; +print ''; + +// Field label font size +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("Parameter").''.$langs->trans("Value").''.$langs->trans("Description").'
'.$langs->trans("PDFFontHeader").''; +print ''; +print ''.$langs->trans("PDFFontHeaderHelp").'
'.$langs->trans("PDFFontContent").''; +print ''; +print ''.$langs->trans("PDFFontContentHelp").'
'.$langs->trans("PDFFontFields").''; +print ''; +print ''.$langs->trans("PDFFontFieldsHelp").'
'; + +print '
'; +print '
'; +print ''; +print '
'; + +print '
'; + +// PDF Template Section +print '

'; +print '
'.$langs->trans("PDFExportTemplate").'
'; +print '

'; + +$templateFile = $uploadDir.'/export_template.pdf'; +$hasTemplate = file_exists($templateFile); + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("PDFTemplate").'
'.$langs->trans("CurrentTemplate").''; +if ($hasTemplate) { + print ''; + print ' export_template.pdf'; + print ''; + print ' ('.dol_print_size(filesize($templateFile)).')'; + print '

'; + print ''; + print ' '.$langs->trans("DeleteTemplate"); + print ''; +} else { + print ''.$langs->trans("NoTemplateUploaded").''; +} +print '
'.$langs->trans("UploadNewTemplate").''; +print '
'; +print ''; +print ''; +print ''; +print ' '; +print '
'; +print '
'.$langs->trans("PDFTemplateHelp").''; +print '
'; + +// Info section +print '
'; +print '
'; +print ''.$langs->trans("ConfigurationHelp").':
'; +print '• '.$langs->trans("ConfigHelpSystems").'
'; +print '• '.$langs->trans("ConfigHelpTypes").'
'; +print '
'; + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/ajax/anlage.php b/ajax/anlage.php new file mode 100755 index 0000000..c9383b8 --- /dev/null +++ b/ajax/anlage.php @@ -0,0 +1,158 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$socId = GETPOSTINT('socid'); +$contactId = GETPOSTINT('contactid'); +$systemId = GETPOSTINT('system_id'); +$anlageId = GETPOSTINT('anlage_id'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +$anlage = new Anlage($db); + +// Helper function to convert tree objects to clean arrays +function treeToArray($nodes) { + $result = array(); + foreach ($nodes as $node) { + $item = array( + 'id' => $node->id, + 'ref' => $node->ref, + 'label' => $node->label, + 'fk_parent' => $node->fk_parent, + 'fk_system' => $node->fk_system, + 'type_label' => $node->type_label, + 'status' => $node->status + ); + if (!empty($node->children)) { + $item['children'] = treeToArray($node->children); + } else { + $item['children'] = array(); + } + $result[] = $item; + } + return $result; +} + +switch ($action) { + case 'tree': + // Get tree structure for a customer/system + if ($socId > 0) { + if ($contactId > 0) { + $tree = $anlage->fetchTreeByContact($socId, $contactId, $systemId); + } else { + $tree = $anlage->fetchTree($socId, $systemId); + } + + // Convert to clean array (removes db connection and other internal data) + $response['success'] = true; + $response['tree'] = treeToArray($tree); + } else { + $response['error'] = 'Missing socid'; + } + break; + + case 'list': + // Get flat list of anlagen for a customer/system (derived from tree) + if ($socId > 0) { + $tree = $anlage->fetchTree($socId, $systemId); + + // Flatten tree to list + $result = array(); + $flattenTree = function($nodes, $prefix = '') use (&$flattenTree, &$result) { + foreach ($nodes as $node) { + $result[] = array( + 'id' => $node->id, + 'ref' => $node->ref, + 'label' => $node->label, + 'display_label' => $prefix . $node->label, + 'fk_parent' => $node->fk_parent, + 'type_label' => $node->type_label, + 'status' => $node->status + ); + if (!empty($node->children)) { + $flattenTree($node->children, $prefix . ' '); + } + } + }; + $flattenTree($tree); + + $response['success'] = true; + $response['anlagen'] = $result; + } else { + $response['error'] = 'Missing socid'; + } + break; + + case 'get': + // Get single anlage + if ($anlageId > 0 && $anlage->fetch($anlageId) > 0) { + $response['success'] = true; + $response['anlage'] = array( + 'id' => $anlage->id, + 'ref' => $anlage->ref, + 'label' => $anlage->label, + 'fk_parent' => $anlage->fk_parent, + 'fk_anlage_type' => $anlage->fk_anlage_type, + 'type_label' => $anlage->type_label, + 'fk_system' => $anlage->fk_system, + 'status' => $anlage->status, + 'field_values' => $anlage->getFieldValues() + ); + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + case 'reorder': + // Reihenfolge der Elemente aktualisieren + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + break; + } + + $idsRaw = GETPOST('ids', 'array'); + if (!empty($idsRaw) && is_array($idsRaw)) { + $ids = array_map('intval', $idsRaw); + $result = $anlage->updateRangs($ids); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = 'Fehler beim Speichern der Reihenfolge'; + } + } else { + $response['error'] = 'Keine IDs übergeben'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/anlage_connection.php b/ajax/anlage_connection.php new file mode 100755 index 0000000..7dad348 --- /dev/null +++ b/ajax/anlage_connection.php @@ -0,0 +1,193 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$connectionId = GETPOSTINT('connection_id'); +$anlageId = GETPOSTINT('anlage_id'); +$socId = GETPOSTINT('soc_id'); +$systemId = GETPOSTINT('system_id'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +$connection = new AnlageConnection($db); + +switch ($action) { + case 'list': + // List connections for an anlage or customer + if ($anlageId > 0) { + $connections = $connection->fetchByAnlage($anlageId); + } elseif ($socId > 0) { + $connections = $connection->fetchBySociete($socId, $systemId); + } else { + $response['error'] = 'Missing anlage_id or soc_id'; + break; + } + + $result = array(); + foreach ($connections as $c) { + $result[] = array( + 'id' => $c->id, + 'fk_source' => $c->fk_source, + 'source_label' => $c->source_label, + 'source_ref' => $c->source_ref, + 'fk_target' => $c->fk_target, + 'target_label' => $c->target_label, + 'target_ref' => $c->target_ref, + 'label' => $c->label, + 'fk_medium_type' => $c->fk_medium_type, + 'medium_type_label' => $c->medium_type_label, + 'medium_type_text' => $c->medium_type_text, + 'medium_spec' => $c->medium_spec, + 'medium_length' => $c->medium_length, + 'medium_color' => $c->medium_color, + 'route_description' => $c->route_description, + 'installation_date' => $c->installation_date, + 'status' => $c->status, + 'display_label' => $c->getDisplayLabel() + ); + } + + $response['success'] = true; + $response['connections'] = $result; + break; + + case 'get': + // Get single connection + if ($connectionId > 0 && $connection->fetch($connectionId) > 0) { + $response['success'] = true; + $response['connection'] = array( + 'id' => $connection->id, + 'fk_source' => $connection->fk_source, + 'source_label' => $connection->source_label, + 'source_ref' => $connection->source_ref, + 'fk_target' => $connection->fk_target, + 'target_label' => $connection->target_label, + 'target_ref' => $connection->target_ref, + 'label' => $connection->label, + 'fk_medium_type' => $connection->fk_medium_type, + 'medium_type_label' => $connection->medium_type_label, + 'medium_type_text' => $connection->medium_type_text, + 'medium_spec' => $connection->medium_spec, + 'medium_length' => $connection->medium_length, + 'medium_color' => $connection->medium_color, + 'route_description' => $connection->route_description, + 'installation_date' => $connection->installation_date, + 'status' => $connection->status + ); + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + case 'create': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + break; + } + + $connection->fk_source = GETPOSTINT('fk_source'); + $connection->fk_target = GETPOSTINT('fk_target'); + $connection->label = GETPOST('label', 'alphanohtml'); + $connection->fk_medium_type = GETPOSTINT('fk_medium_type'); + $connection->medium_type_text = GETPOST('medium_type_text', 'alphanohtml'); + $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + $connection->medium_color = GETPOST('medium_color', 'alphanohtml'); + $connection->route_description = GETPOST('route_description', 'restricthtml'); + $connection->installation_date = GETPOST('installation_date', 'alpha'); + $connection->status = 1; + + if (empty($connection->fk_source) || empty($connection->fk_target)) { + $response['error'] = $langs->trans('ErrorFieldRequired', 'Source/Target'); + break; + } + + $result = $connection->create($user); + if ($result > 0) { + $response['success'] = true; + $response['connection_id'] = $result; + } else { + $response['error'] = $connection->error; + } + break; + + case 'update': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + break; + } + + if ($connection->fetch($connectionId) > 0) { + if (GETPOSTISSET('fk_source')) $connection->fk_source = GETPOSTINT('fk_source'); + if (GETPOSTISSET('fk_target')) $connection->fk_target = GETPOSTINT('fk_target'); + if (GETPOSTISSET('label')) $connection->label = GETPOST('label', 'alphanohtml'); + if (GETPOSTISSET('fk_medium_type')) $connection->fk_medium_type = GETPOSTINT('fk_medium_type'); + if (GETPOSTISSET('medium_type_text')) $connection->medium_type_text = GETPOST('medium_type_text', 'alphanohtml'); + if (GETPOSTISSET('medium_spec')) $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + if (GETPOSTISSET('medium_length')) $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + if (GETPOSTISSET('medium_color')) $connection->medium_color = GETPOST('medium_color', 'alphanohtml'); + if (GETPOSTISSET('route_description')) $connection->route_description = GETPOST('route_description', 'restricthtml'); + if (GETPOSTISSET('installation_date')) $connection->installation_date = GETPOST('installation_date', 'alpha'); + if (GETPOSTISSET('status')) $connection->status = GETPOSTINT('status'); + + $result = $connection->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $connection->error; + } + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + case 'delete': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + break; + } + + if ($connection->fetch($connectionId) > 0) { + $result = $connection->delete($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $connection->error; + } + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/anlage_docs.php b/ajax/anlage_docs.php new file mode 100755 index 0000000..9e4e40b --- /dev/null +++ b/ajax/anlage_docs.php @@ -0,0 +1,61 @@ +hasRight('kundenkarte', 'read')) { + http_response_code(403); + echo json_encode(['error' => 'Permission denied']); + exit; +} + +$anlageId = GETPOSTINT('anlage_id'); + +if ($anlageId <= 0) { + echo json_encode(array('error' => 'Invalid ID', 'docs' => array())); + exit; +} + +// Get the anlage to know the socid +$anlage = new Anlage($db); +if ($anlage->fetch($anlageId) <= 0) { + echo json_encode(array('error' => 'Element not found', 'docs' => array())); + exit; +} + +// Get documents for this element (pdf and document types) +$anlagefile = new AnlageFile($db); +$files = $anlagefile->fetchAllByAnlage($anlageId); + +$docs = array(); +foreach ($files as $file) { + // Only include PDFs and documents, not images + if (in_array($file->file_type, array('pdf', 'document'))) { + $docs[] = array( + 'id' => $file->id, + 'name' => $file->filename, + 'url' => $file->getUrl(), + 'type' => $file->file_type, + 'icon' => $file->file_type == 'pdf' ? 'fa-file-pdf-o' : 'fa-file-text-o', + ); + } +} + +echo json_encode(array('docs' => $docs)); diff --git a/ajax/anlage_images.php b/ajax/anlage_images.php new file mode 100755 index 0000000..747525c --- /dev/null +++ b/ajax/anlage_images.php @@ -0,0 +1,57 @@ +hasRight('kundenkarte', 'read')) { + http_response_code(403); + echo json_encode(['error' => 'Permission denied']); + exit; +} + +$anlageId = GETPOSTINT('anlage_id'); + +if ($anlageId <= 0) { + echo json_encode(array('error' => 'Invalid ID', 'images' => array())); + exit; +} + +// Get the anlage to know the socid +$anlage = new Anlage($db); +if ($anlage->fetch($anlageId) <= 0) { + echo json_encode(array('error' => 'Element not found', 'images' => array())); + exit; +} + +// Get images for this element +$anlagefile = new AnlageFile($db); +$files = $anlagefile->fetchAllByAnlage($anlageId, 'image'); + +$images = array(); +foreach ($files as $file) { + $images[] = array( + 'id' => $file->id, + 'name' => $file->filename, + 'url' => $file->getUrl(), + 'thumb' => $file->getThumbUrl() ?: $file->getUrl(), + ); +} + +echo json_encode(array('images' => $images)); diff --git a/ajax/anlage_tooltip.php b/ajax/anlage_tooltip.php new file mode 100755 index 0000000..55f0bef --- /dev/null +++ b/ajax/anlage_tooltip.php @@ -0,0 +1,106 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +header('Content-Type: application/json'); + +$id = GETPOSTINT('id'); + +if (!$user->hasRight('kundenkarte', 'read')) { + echo json_encode(array('success' => false, 'error' => 'Access denied')); + exit; +} + +if ($id <= 0) { + echo json_encode(array('success' => false, 'error' => 'Invalid ID')); + exit; +} + +$anlage = new Anlage($db); +$result = $anlage->fetch($id); + +if ($result <= 0) { + echo json_encode(array('success' => false, 'error' => 'Element not found')); + exit; +} + +// Get type and fields +$type = new AnlageType($db); +$type->fetch($anlage->fk_anlage_type); +$typeFields = $type->fetchFields(); + +// Get field values +$fieldValues = $anlage->getFieldValues(); +$fieldsData = array(); + +foreach ($typeFields as $field) { + if ($field->show_in_hover) { + // Handle header fields + if ($field->field_type === 'header') { + $fieldsData[$field->field_code] = array( + 'label' => $field->field_label, + 'value' => '', + 'type' => 'header' + ); + continue; + } + + $value = isset($fieldValues[$field->field_code]) ? $fieldValues[$field->field_code] : ''; + + // Format date values + $displayValue = $value; + if ($field->field_type === 'date' && $value) { + $displayValue = dol_print_date(strtotime($value), 'day'); + } + + $fieldsData[$field->field_code] = array( + 'label' => $field->field_label, + 'value' => $displayValue, + 'type' => $field->field_type + ); + } +} + +// Get images +$anlagefile = new AnlageFile($db); +$files = $anlagefile->fetchAllByAnlage($id, 'image'); + +$images = array(); +foreach ($files as $file) { + $images[] = array( + 'id' => $file->id, + 'filename' => $file->filename, + 'url' => $file->getUrl(), + 'thumb_url' => $file->getThumbUrl() ?: $file->getUrl() + ); +} + +$data = array( + 'id' => $anlage->id, + 'label' => $anlage->label, + 'type_label' => $anlage->type_label, + 'picto' => $anlage->type_picto, + 'note_html' => $anlage->note_private ? nl2br(htmlspecialchars($anlage->note_private, ENT_QUOTES, 'UTF-8')) : '', + 'fields' => $fieldsData, + 'images' => $images +); + +echo json_encode(array('success' => true, 'data' => $data)); diff --git a/ajax/audit_log.php b/ajax/audit_log.php new file mode 100755 index 0000000..9480657 --- /dev/null +++ b/ajax/audit_log.php @@ -0,0 +1,153 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$objectType = GETPOST('object_type', 'aZ09'); +$objectId = GETPOSTINT('object_id'); +$anlageId = GETPOSTINT('anlage_id'); +$socid = GETPOSTINT('socid'); +$limit = GETPOSTINT('limit') ?: 50; + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +$auditLog = new AuditLog($db); + +switch ($action) { + case 'fetch_object': + // Fetch logs for a specific object + if (empty($objectType) || $objectId <= 0) { + $response['error'] = $langs->trans('ErrorMissingParameters'); + break; + } + + $logs = $auditLog->fetchByObject($objectType, $objectId, $limit); + + $response['success'] = true; + $response['logs'] = array(); + + foreach ($logs as $log) { + $response['logs'][] = array( + 'id' => $log->id, + 'object_type' => $log->object_type, + 'object_type_label' => $log->getObjectTypeLabel(), + 'object_id' => $log->object_id, + 'object_ref' => $log->object_ref, + 'action' => $log->action, + 'action_label' => $log->getActionLabel(), + 'action_icon' => $log->getActionIcon(), + 'action_color' => $log->getActionColor(), + 'field_changed' => $log->field_changed, + 'old_value' => $log->old_value, + 'new_value' => $log->new_value, + 'user_login' => $log->user_login, + 'user_name' => $log->user_name ?: $log->user_login, + 'date_action' => dol_print_date($log->date_action, 'dayhour'), + 'timestamp' => $log->date_action, + 'note' => $log->note + ); + } + break; + + case 'fetch_anlage': + // Fetch logs for an Anlage + if ($anlageId <= 0) { + $response['error'] = $langs->trans('ErrorMissingParameters'); + break; + } + + $logs = $auditLog->fetchByAnlage($anlageId, $limit); + + $response['success'] = true; + $response['logs'] = array(); + + foreach ($logs as $log) { + $response['logs'][] = array( + 'id' => $log->id, + 'object_type' => $log->object_type, + 'object_type_label' => $log->getObjectTypeLabel(), + 'object_id' => $log->object_id, + 'object_ref' => $log->object_ref, + 'action' => $log->action, + 'action_label' => $log->getActionLabel(), + 'action_icon' => $log->getActionIcon(), + 'action_color' => $log->getActionColor(), + 'field_changed' => $log->field_changed, + 'old_value' => $log->old_value, + 'new_value' => $log->new_value, + 'user_login' => $log->user_login, + 'user_name' => $log->user_name ?: $log->user_login, + 'date_action' => dol_print_date($log->date_action, 'dayhour'), + 'timestamp' => $log->date_action, + 'note' => $log->note + ); + } + break; + + case 'fetch_societe': + // Fetch logs for a customer + if ($socid <= 0) { + $response['error'] = $langs->trans('ErrorMissingParameters'); + break; + } + + $logs = $auditLog->fetchBySociete($socid, $limit); + + $response['success'] = true; + $response['logs'] = array(); + + foreach ($logs as $log) { + $response['logs'][] = array( + 'id' => $log->id, + 'object_type' => $log->object_type, + 'object_type_label' => $log->getObjectTypeLabel(), + 'object_id' => $log->object_id, + 'object_ref' => $log->object_ref, + 'fk_anlage' => $log->fk_anlage, + 'action' => $log->action, + 'action_label' => $log->getActionLabel(), + 'action_icon' => $log->getActionIcon(), + 'action_color' => $log->getActionColor(), + 'field_changed' => $log->field_changed, + 'old_value' => $log->old_value, + 'new_value' => $log->new_value, + 'user_login' => $log->user_login, + 'user_name' => $log->user_name ?: $log->user_login, + 'date_action' => dol_print_date($log->date_action, 'dayhour'), + 'timestamp' => $log->date_action, + 'note' => $log->note + ); + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/bom_generator.php b/ajax/bom_generator.php new file mode 100755 index 0000000..50f9557 --- /dev/null +++ b/ajax/bom_generator.php @@ -0,0 +1,250 @@ +loadLangs(array('kundenkarte@kundenkarte', 'products')); + +$action = GETPOST('action', 'aZ09'); +$anlageId = GETPOSTINT('anlage_id'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +switch ($action) { + case 'generate': + // Generate BOM from all equipment in this installation (anlage) + if ($anlageId <= 0) { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + break; + } + + // Get all equipment for this anlage through carriers and panels + $sql = "SELECT e.rowid as equipment_id, e.label as equipment_label, e.width_te, e.fk_product,"; + $sql .= " et.rowid as type_id, et.ref as type_ref, et.label as type_label, et.fk_product as type_product,"; + $sql .= " p.rowid as product_id, p.ref as product_ref, p.label as product_label, p.price, p.tva_tx,"; + $sql .= " c.label as carrier_label, c.rowid as carrier_id,"; + $sql .= " pan.label as panel_label, pan.rowid as panel_id"; + $sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment e"; + $sql .= " JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_carrier c ON e.fk_carrier = c.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_panel pan ON c.fk_panel = pan.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_type et ON e.fk_equipment_type = et.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON COALESCE(e.fk_product, et.fk_product) = p.rowid"; + $sql .= " WHERE (pan.fk_anlage = ".((int) $anlageId)." OR c.fk_anlage = ".((int) $anlageId).")"; + $sql .= " AND e.status = 1"; + $sql .= " ORDER BY pan.position ASC, c.position ASC, e.position_te ASC"; + + $resql = $db->query($sql); + if (!$resql) { + $response['error'] = $db->lasterror(); + break; + } + + $items = array(); + $summary = array(); // Grouped by product + + while ($obj = $db->fetch_object($resql)) { + $item = array( + 'equipment_id' => $obj->equipment_id, + 'equipment_label' => $obj->equipment_label ?: $obj->type_label, + 'type_ref' => $obj->type_ref, + 'type_label' => $obj->type_label, + 'width_te' => $obj->width_te, + 'carrier_label' => $obj->carrier_label, + 'panel_label' => $obj->panel_label, + 'product_id' => $obj->product_id, + 'product_ref' => $obj->product_ref, + 'product_label' => $obj->product_label, + 'price' => $obj->price, + 'tva_tx' => $obj->tva_tx + ); + $items[] = $item; + + // Group by product for summary + if ($obj->product_id) { + $key = $obj->product_id; + if (!isset($summary[$key])) { + $summary[$key] = array( + 'product_id' => $obj->product_id, + 'product_ref' => $obj->product_ref, + 'product_label' => $obj->product_label, + 'price' => $obj->price, + 'tva_tx' => $obj->tva_tx, + 'quantity' => 0, + 'total' => 0 + ); + } + $summary[$key]['quantity']++; + $summary[$key]['total'] = $summary[$key]['quantity'] * $summary[$key]['price']; + } else { + // Group by type if no product linked + $key = 'type_'.$obj->type_id; + if (!isset($summary[$key])) { + $summary[$key] = array( + 'product_id' => null, + 'product_ref' => $obj->type_ref, + 'product_label' => $obj->type_label.' (kein Produkt)', + 'price' => 0, + 'tva_tx' => 0, + 'quantity' => 0, + 'total' => 0 + ); + } + $summary[$key]['quantity']++; + } + } + $db->free($resql); + + // Also include busbar types (connections with is_rail = 1) + $sql = "SELECT conn.rowid as connection_id, conn.rail_phases, conn.rail_start_te, conn.rail_end_te,"; + $sql .= " bt.rowid as busbar_type_id, bt.ref as busbar_ref, bt.label as busbar_label, bt.fk_product,"; + $sql .= " p.rowid as product_id, p.ref as product_ref, p.label as product_label, p.price, p.tva_tx,"; + $sql .= " c.label as carrier_label, pan.label as panel_label"; + $sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection conn"; + $sql .= " JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_carrier c ON conn.fk_carrier = c.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_panel pan ON c.fk_panel = pan.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_busbar_type bt ON conn.fk_busbar_type = bt.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON bt.fk_product = p.rowid"; + $sql .= " WHERE (pan.fk_anlage = ".((int) $anlageId)." OR c.fk_anlage = ".((int) $anlageId).")"; + $sql .= " AND conn.is_rail = 1"; + $sql .= " AND conn.status = 1"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + // Calculate busbar length in TE + $lengthTE = max(1, intval($obj->rail_end_te) - intval($obj->rail_start_te) + 1); + + $item = array( + 'equipment_id' => 'busbar_'.$obj->connection_id, + 'equipment_label' => $obj->busbar_label ?: 'Sammelschiene '.$obj->rail_phases, + 'type_ref' => $obj->busbar_ref ?: 'BUSBAR', + 'type_label' => 'Sammelschiene', + 'width_te' => $lengthTE, + 'carrier_label' => $obj->carrier_label, + 'panel_label' => $obj->panel_label, + 'product_id' => $obj->product_id, + 'product_ref' => $obj->product_ref, + 'product_label' => $obj->product_label, + 'price' => $obj->price, + 'tva_tx' => $obj->tva_tx + ); + $items[] = $item; + + // Add to summary + if ($obj->product_id) { + $key = $obj->product_id; + if (!isset($summary[$key])) { + $summary[$key] = array( + 'product_id' => $obj->product_id, + 'product_ref' => $obj->product_ref, + 'product_label' => $obj->product_label, + 'price' => $obj->price, + 'tva_tx' => $obj->tva_tx, + 'quantity' => 0, + 'total' => 0 + ); + } + $summary[$key]['quantity']++; + $summary[$key]['total'] = $summary[$key]['quantity'] * $summary[$key]['price']; + } + } + $db->free($resql); + } + + // Calculate totals + $totalQuantity = 0; + $totalPrice = 0; + foreach ($summary as $s) { + $totalQuantity += $s['quantity']; + $totalPrice += $s['total']; + } + + $response['success'] = true; + $response['items'] = $items; + $response['summary'] = array_values($summary); + $response['total_quantity'] = $totalQuantity; + $response['total_price'] = $totalPrice; + break; + + case 'create_order': + // Create a Dolibarr order from the BOM + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + break; + } + + $socid = GETPOSTINT('socid'); + $productData = GETPOST('products', 'array'); + + if ($socid <= 0 || empty($productData)) { + $response['error'] = $langs->trans('ErrorMissingParameters'); + break; + } + + require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; + + $order = new Commande($db); + $order->socid = $socid; + $order->date_commande = dol_now(); + $order->note_private = 'Generiert aus Schaltplan-Stückliste'; + $order->source = 1; // Web + + $result = $order->create($user); + if ($result <= 0) { + $response['error'] = $order->error ?: 'Fehler beim Erstellen der Bestellung'; + break; + } + + // Add lines + $lineErrors = 0; + foreach ($productData as $prod) { + $productId = intval($prod['product_id']); + $qty = floatval($prod['quantity']); + + if ($productId <= 0 || $qty <= 0) continue; + + $result = $order->addline( + '', // Description (auto from product) + 0, // Unit price (auto from product) + $qty, + 0, // TVA rate (auto) + 0, 0, // Remise + $productId + ); + + if ($result < 0) { + $lineErrors++; + } + } + + $response['success'] = true; + $response['order_id'] = $order->id; + $response['order_ref'] = $order->ref; + $response['line_errors'] = $lineErrors; + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/building_types.php b/ajax/building_types.php new file mode 100755 index 0000000..36b1d00 --- /dev/null +++ b/ajax/building_types.php @@ -0,0 +1,121 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$levelType = GETPOST('level_type', 'alpha'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +$buildingType = new BuildingType($db); + +switch ($action) { + case 'list': + // Get all building types + $types = $buildingType->fetchAll(1, $levelType); + + $result = array(); + foreach ($types as $t) { + $result[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'level_type' => $t->level_type, + 'level_type_label' => $t->getLevelTypeLabel(), + 'icon' => $t->icon, + 'color' => $t->color, + 'can_have_children' => $t->can_have_children, + 'is_system' => $t->is_system + ); + } + + $response['success'] = true; + $response['types'] = $result; + break; + + case 'list_grouped': + // Get types grouped by level + $grouped = $buildingType->fetchGroupedByLevel(1); + + $result = array(); + $levelTypes = BuildingType::getLevelTypes(); + + foreach ($grouped as $level => $types) { + $levelLabel = isset($levelTypes[$level]) ? $levelTypes[$level] : $level; + $levelTypes_data = array(); + foreach ($types as $t) { + $levelTypes_data[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'icon' => $t->icon, + 'color' => $t->color, + 'can_have_children' => $t->can_have_children + ); + } + $result[] = array( + 'level_type' => $level, + 'level_type_label' => $levelLabel, + 'types' => $levelTypes_data + ); + } + + $response['success'] = true; + $response['groups'] = $result; + break; + + case 'get': + // Get single type details + $typeId = GETPOSTINT('type_id'); + if ($typeId > 0 && $buildingType->fetch($typeId) > 0) { + $response['success'] = true; + $response['type'] = array( + 'id' => $buildingType->id, + 'ref' => $buildingType->ref, + 'label' => $buildingType->label, + 'label_short' => $buildingType->label_short, + 'description' => $buildingType->description, + 'level_type' => $buildingType->level_type, + 'level_type_label' => $buildingType->getLevelTypeLabel(), + 'icon' => $buildingType->icon, + 'color' => $buildingType->color, + 'can_have_children' => $buildingType->can_have_children, + 'is_system' => $buildingType->is_system + ); + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/equipment.php b/ajax/equipment.php new file mode 100755 index 0000000..6114044 --- /dev/null +++ b/ajax/equipment.php @@ -0,0 +1,687 @@ + false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = 'Permission denied'; + echo json_encode($response); + exit; +} + +switch ($action) { + case 'get_products': + // Get products for equipment selection (electrical components) + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + $search = GETPOST('search', 'alphanohtml'); + $limit = GETPOSTINT('limit') ?: 50; + + $sql = "SELECT p.rowid, p.ref, p.label, p.price, p.fk_product_type"; + $sql .= " FROM ".MAIN_DB_PREFIX."product as p"; + $sql .= " WHERE p.entity IN (".getEntity('product').")"; + $sql .= " AND p.tosell = 1"; + if (!empty($search)) { + $sql .= " AND (p.ref LIKE '%".$db->escape($search)."%' OR p.label LIKE '%".$db->escape($search)."%')"; + } + $sql .= " ORDER BY p.ref ASC"; + $sql .= " LIMIT ".((int) $limit); + + $resql = $db->query($sql); + $products = array(); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $products[] = array( + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'label' => $obj->label, + 'price' => $obj->price, + 'display' => $obj->ref.' - '.$obj->label + ); + } + } + $response['success'] = true; + $response['products'] = $products; + break; + + case 'get_product': + // Get single product by ID + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + $productId = GETPOSTINT('product_id'); + if ($productId > 0) { + $product = new Product($db); + if ($product->fetch($productId) > 0) { + $response['success'] = true; + $response['product'] = array( + 'id' => $product->id, + 'ref' => $product->ref, + 'label' => $product->label, + 'display' => $product->ref.' - '.$product->label + ); + } else { + $response['error'] = 'Product not found'; + } + } else { + $response['error'] = 'No product_id provided'; + } + break; + + case 'get_types': + // Get all equipment types for dropdown + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmenttype.class.php'; + $eqType = new EquipmentType($db); + $systemId = GETPOSTINT('system_id'); + $types = $eqType->fetchAllBySystem($systemId, 1); // Filter by system if provided, only active + + $result = array(); + foreach ($types as $t) { + $result[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'width_te' => $t->width_te, + 'color' => $t->color, + 'picto' => $t->picto + ); + } + $response['success'] = true; + $response['types'] = $result; + break; + + case 'get_type_fields': + // Get fields for a specific equipment type + $typeId = GETPOSTINT('type_id'); + if ($typeId > 0) { + $sql = "SELECT field_code, field_label, field_type, field_options, required, position, show_on_block, show_in_hover"; + $sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " WHERE fk_equipment_type = ".((int) $typeId); + $sql .= " AND active = 1"; + $sql .= " ORDER BY position ASC"; + + $resql = $db->query($sql); + $fields = array(); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $fields[] = array( + 'field_code' => $obj->field_code, + 'field_label' => $obj->field_label, + 'field_type' => $obj->field_type, + 'field_options' => $obj->field_options, + 'required' => $obj->required, + 'show_on_block' => $obj->show_on_block, + 'show_in_hover' => $obj->show_in_hover + ); + } + } + $response['success'] = true; + $response['fields'] = $fields; + } else { + $response['error'] = 'No type_id provided'; + } + break; + + case 'get': + // Get single equipment data + if ($equipmentId > 0 && $equipment->fetch($equipmentId) > 0) { + $response['success'] = true; + $response['equipment'] = array( + 'id' => $equipment->id, + 'fk_carrier' => $equipment->fk_carrier, + 'type_id' => $equipment->fk_equipment_type, + 'type_label' => $equipment->type_label, + 'type_label_short' => $equipment->type_label_short, + 'type_color' => $equipment->type_color, + 'type_icon_file' => $equipment->type_icon_file, + 'label' => $equipment->label, + 'position_te' => $equipment->position_te, + 'width_te' => $equipment->width_te, + 'field_values' => $equipment->getFieldValues(), + 'fk_product' => $equipment->fk_product, + 'fk_protection' => $equipment->fk_protection, + 'protection_label' => $equipment->protection_label + ); + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'get_protection_devices': + // Get all protection devices (FI/RCD) for an Anlage + $anlageId = GETPOSTINT('anlage_id'); + if ($anlageId > 0) { + $devices = $equipment->fetchProtectionDevices($anlageId); + $result = array(); + foreach ($devices as $d) { + $result[] = array( + 'id' => $d->id, + 'label' => $d->label ?: $d->type_label, + 'type_label' => $d->type_label, + 'type_label_short' => $d->type_label_short, + 'display_label' => ($d->label ?: $d->type_label_short ?: $d->type_label).' (Pos. '.$d->position_te.')' + ); + } + $response['success'] = true; + $response['devices'] = $result; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'list': + // List all equipment on a carrier + if ($carrierId > 0) { + $items = $equipment->fetchByCarrier($carrierId); + $result = array(); + + // Cache type fields for performance + $typeFieldsCache = array(); + + foreach ($items as $eq) { + $iconUrl = ''; + if (!empty($eq->type_icon_file)) { + $iconUrl = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=equipment_icons/'.urlencode($eq->type_icon_file); + } + + // Load type fields if not cached + $typeId = $eq->fk_equipment_type; + if (!isset($typeFieldsCache[$typeId])) { + $typeFieldsCache[$typeId] = array(); + $sql = "SELECT field_code, field_label, show_on_block, show_in_hover"; + $sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " WHERE fk_equipment_type = ".((int) $typeId); + $sql .= " AND active = 1"; + $sql .= " ORDER BY position ASC"; + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $typeFieldsCache[$typeId][] = array( + 'field_code' => $obj->field_code, + 'field_label' => $obj->field_label, + 'show_on_block' => (int) $obj->show_on_block, + 'show_in_hover' => (int) $obj->show_in_hover + ); + } + } + } + + // Load product data if assigned + $productRef = ''; + $productLabel = ''; + if (!empty($eq->fk_product)) { + $sqlProd = "SELECT ref, label FROM ".MAIN_DB_PREFIX."product WHERE rowid = ".((int) $eq->fk_product); + $resProd = $db->query($sqlProd); + if ($resProd && ($objProd = $db->fetch_object($resProd))) { + $productRef = $objProd->ref; + $productLabel = $objProd->label; + } + } + + $result[] = array( + 'id' => $eq->id, + 'type_id' => $eq->fk_equipment_type, + 'type_label' => $eq->type_label, + 'type_label_short' => $eq->type_label_short, + 'type_ref' => $eq->type_ref, + 'type_color' => $eq->type_color, + 'type_icon_file' => $eq->type_icon_file, + 'type_icon_url' => $iconUrl, + 'type_block_image' => $eq->type_block_image, + 'type_block_image_url' => !empty($eq->type_block_image) ? DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.urlencode($eq->type_block_image) : '', + 'type_flow_direction' => $eq->type_flow_direction, + 'type_terminal_position' => $eq->type_terminal_position ?: 'both', + 'terminals_config' => $eq->terminals_config, + 'type_fields' => $typeFieldsCache[$typeId], + 'label' => $eq->label, + 'position_te' => $eq->position_te, + 'width_te' => $eq->width_te, + 'block_label' => $eq->getBlockLabel(), + 'block_color' => $eq->getBlockColor(), + 'field_values' => $eq->getFieldValues(), + 'fk_product' => $eq->fk_product, + 'product_ref' => $productRef, + 'product_label' => $productLabel + ); + } + $response['success'] = true; + $response['equipment'] = $result; + } else { + $response['error'] = 'Missing carrier_id'; + } + break; + + case 'create': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $equipment->fk_carrier = $carrierId; + $equipment->fk_equipment_type = GETPOSTINT('type_id'); + $equipment->label = GETPOST('label', 'alphanohtml'); + $equipment->position_te = GETPOSTINT('position_te'); + $equipment->width_te = GETPOSTINT('width_te'); + $equipment->fk_product = GETPOSTINT('fk_product'); + $equipment->fk_protection = GETPOSTINT('fk_protection'); + $equipment->protection_label = GETPOST('protection_label', 'alphanohtml'); + + // Field values + $fieldValues = GETPOST('field_values', 'nohtml'); + if ($fieldValues) { + $equipment->field_values = $fieldValues; + } + + // If no width specified, get from type + if (empty($equipment->width_te)) { + $type = new EquipmentType($db); + if ($type->fetch($equipment->fk_equipment_type) > 0) { + $equipment->width_te = $type->width_te; + } else { + $equipment->width_te = 1; + } + } + + // Check carrier and position + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($carrierId) > 0) { + // If no position specified, find next free position (1-based) + if (empty($equipment->position_te)) { + $equipment->position_te = $carrier->getNextFreePosition($equipment->width_te); + if ($equipment->position_te < 0) { + $response['error'] = 'No free position available on carrier'; + break; + } + } + + // Check if position is available + if (!$carrier->isPositionAvailable($equipment->position_te, $equipment->width_te)) { + $response['error'] = 'Position not available'; + break; + } + + // Auto-generate label if empty: R2.1 or R2.1-3 for multi-TE + if (empty($equipment->label)) { + $carrierLabel = $carrier->label ?: ('R'.$carrier->id); + $posStart = $equipment->position_te; + $posEnd = $posStart + $equipment->width_te - 1; + if ($equipment->width_te > 1) { + $equipment->label = $carrierLabel.'.'.$posStart.'-'.$posEnd; + } else { + $equipment->label = $carrierLabel.'.'.$posStart; + } + } + } + + $result = $equipment->create($user); + if ($result > 0) { + $response['success'] = true; + $response['equipment_id'] = $result; + $response['block_label'] = $equipment->getBlockLabel(); + + // Audit log + $anlageId = 0; + if ($carrier->fk_panel > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php'; + $panel = new EquipmentPanel($db); + if ($panel->fetch($carrier->fk_panel) > 0) { + $anlageId = $panel->fk_anlage; + } + } else { + $anlageId = $carrier->fk_anlage; + } + $auditLog->logCreate($user, AuditLog::TYPE_EQUIPMENT, $result, $equipment->label ?: $equipment->type_label, 0, $anlageId, array( + 'type_id' => $equipment->fk_equipment_type, + 'position_te' => $equipment->position_te, + 'width_te' => $equipment->width_te + )); + } else { + $response['error'] = $equipment->error; + } + break; + + case 'update': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + $newPosition = GETPOSTINT('position_te'); + $newWidth = GETPOSTINT('width_te') ?: $equipment->width_te; + + // Check if new position is available (excluding current equipment) + if ($newPosition != $equipment->position_te || $newWidth != $equipment->width_te) { + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($equipment->fk_carrier) > 0) { + if (!$carrier->isPositionAvailable($newPosition, $newWidth, $equipmentId)) { + $response['error'] = 'Position not available'; + break; + } + } + } + + $equipment->fk_equipment_type = GETPOSTINT('type_id') ?: $equipment->fk_equipment_type; + $equipment->label = GETPOST('label', 'alphanohtml'); + $equipment->position_te = $newPosition; + $equipment->width_te = $newWidth; + $equipment->fk_product = GETPOSTINT('fk_product'); + $equipment->fk_protection = GETPOSTINT('fk_protection'); + $equipment->protection_label = GETPOST('protection_label', 'alphanohtml'); + + $fieldValues = GETPOST('field_values', 'nohtml'); + if ($fieldValues) { + $equipment->field_values = $fieldValues; + } + + // Auto-generate label if empty + if (empty(trim($equipment->label))) { + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($equipment->fk_carrier) > 0) { + $carrierLabel = $carrier->label ?: ('R'.$carrier->id); + $posStart = $equipment->position_te; + $posEnd = $posStart + $equipment->width_te - 1; + if ($equipment->width_te > 1) { + $equipment->label = $carrierLabel.'.'.$posStart.'-'.$posEnd; + } else { + $equipment->label = $carrierLabel.'.'.$posStart; + } + } + } + + $oldLabel = isset($oldLabel) ? $oldLabel : $equipment->label; + $oldPosition = isset($oldPosition) ? $oldPosition : $equipment->position_te; + $result = $equipment->update($user); + if ($result > 0) { + $response['success'] = true; + $response['block_label'] = $equipment->getBlockLabel(); + + // Audit log + $anlageId = 0; + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($equipment->fk_carrier) > 0) { + if ($carrier->fk_panel > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php'; + $panel = new EquipmentPanel($db); + if ($panel->fetch($carrier->fk_panel) > 0) { + $anlageId = $panel->fk_anlage; + } + } else { + $anlageId = $carrier->fk_anlage; + } + } + $auditLog->logUpdate($user, AuditLog::TYPE_EQUIPMENT, $equipment->id, $equipment->label ?: $equipment->type_label, 'properties', null, null, 0, $anlageId); + } else { + $response['error'] = $equipment->error; + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'update_position': + // Quick position update for drag-drop + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + $newPosition = GETPOSTINT('position_te'); + + // Check if new position is available + $carrier = new EquipmentCarrier($db); + if ($newPosition != $equipment->position_te) { + if ($carrier->fetch($equipment->fk_carrier) > 0) { + if (!$carrier->isPositionAvailable($newPosition, $equipment->width_te, $equipmentId)) { + $response['error'] = 'Position not available'; + break; + } + } + } else { + $carrier->fetch($equipment->fk_carrier); + } + + // Update auto-generated label if it matches the pattern Rxx.xx or Rxx.xx-xx + $oldLabel = $equipment->label; + $carrierLabel = $carrier->label ?: ('R'.$carrier->id); + if (preg_match('/^'.preg_quote($carrierLabel, '/').'\.(\d+)(-\d+)?$/', $oldLabel)) { + $posStart = $newPosition; + $posEnd = $posStart + $equipment->width_te - 1; + if ($equipment->width_te > 1) { + $equipment->label = $carrierLabel.'.'.$posStart.'-'.$posEnd; + } else { + $equipment->label = $carrierLabel.'.'.$posStart; + } + } + + $equipment->position_te = $newPosition; + $result = $equipment->update($user); + if ($result > 0) { + $response['success'] = true; + $response['new_label'] = $equipment->label; + } else { + $response['error'] = $equipment->error; + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'move_to_carrier': + // Move equipment to different carrier (drag-drop between carriers) + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + $newCarrierId = GETPOSTINT('carrier_id'); + $newPosition = GETPOSTINT('position_te') ?: 1; + + // Get old carrier for label pattern check + $oldCarrier = new EquipmentCarrier($db); + $oldCarrier->fetch($equipment->fk_carrier); + $oldCarrierLabel = $oldCarrier->label ?: ('R'.$oldCarrier->id); + + // Check if target carrier exists + $targetCarrier = new EquipmentCarrier($db); + if ($targetCarrier->fetch($newCarrierId) <= 0) { + $response['error'] = 'Target carrier not found'; + break; + } + + // Check if position is available on target carrier + if (!$targetCarrier->isPositionAvailable($newPosition, $equipment->width_te, 0)) { + $response['error'] = 'Position auf Ziel-Hutschiene nicht verfügbar'; + break; + } + + // Update auto-generated label if it matches the old carrier pattern + $oldLabel = $equipment->label; + $newCarrierLabel = $targetCarrier->label ?: ('R'.$targetCarrier->id); + if (preg_match('/^'.preg_quote($oldCarrierLabel, '/').'\.(\d+)(-\d+)?$/', $oldLabel)) { + $posStart = $newPosition; + $posEnd = $posStart + $equipment->width_te - 1; + if ($equipment->width_te > 1) { + $equipment->label = $newCarrierLabel.'.'.$posStart.'-'.$posEnd; + } else { + $equipment->label = $newCarrierLabel.'.'.$posStart; + } + } + + // Update equipment + $equipment->fk_carrier = $newCarrierId; + $equipment->position_te = $newPosition; + $result = $equipment->update($user); + if ($result > 0) { + $response['success'] = true; + $response['message'] = 'Equipment verschoben'; + $response['new_label'] = $equipment->label; + } else { + $response['error'] = $equipment->error; + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'delete': + if (!$user->hasRight('kundenkarte', 'delete')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + // Get anlage_id before deletion for audit log + $anlageId = 0; + $deletedLabel = $equipment->label ?: $equipment->type_label; + $deletedData = array( + 'type_id' => $equipment->fk_equipment_type, + 'type_label' => $equipment->type_label, + 'position_te' => $equipment->position_te, + 'width_te' => $equipment->width_te, + 'carrier_id' => $equipment->fk_carrier + ); + + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($equipment->fk_carrier) > 0) { + if ($carrier->fk_panel > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php'; + $panel = new EquipmentPanel($db); + if ($panel->fetch($carrier->fk_panel) > 0) { + $anlageId = $panel->fk_anlage; + } + } else { + $anlageId = $carrier->fk_anlage; + } + } + + $result = $equipment->delete($user); + if ($result > 0) { + $response['success'] = true; + + // Audit log + $auditLog->logDelete($user, AuditLog::TYPE_EQUIPMENT, $equipmentId, $deletedLabel, 0, $anlageId, $deletedData); + } else { + $response['error'] = $equipment->error; + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'duplicate': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + $sourceId = $equipmentId; + $newId = $equipment->duplicate($user); + if ($newId > 0) { + $response['success'] = true; + $response['equipment_id'] = $newId; + + // Fetch the new equipment to return its data + $newEquipment = new Equipment($db); + if ($newEquipment->fetch($newId) > 0) { + $response['equipment'] = array( + 'id' => $newEquipment->id, + 'fk_carrier' => $newEquipment->fk_carrier, + 'type_id' => $newEquipment->fk_equipment_type, + 'type_label' => $newEquipment->type_label, + 'type_label_short' => $newEquipment->type_label_short, + 'type_color' => $newEquipment->type_color, + 'type_ref' => $newEquipment->type_ref, + 'type_icon_file' => $newEquipment->type_icon_file, + 'terminals_config' => $newEquipment->terminals_config, + 'label' => $newEquipment->label, + 'position_te' => $newEquipment->position_te, + 'width_te' => $newEquipment->width_te, + 'block_label' => $newEquipment->getBlockLabel(), + 'block_color' => $newEquipment->getBlockColor(), + 'field_values' => $newEquipment->getFieldValues(), + 'fk_product' => $newEquipment->fk_product + ); + + // Audit log + $anlageId = 0; + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($newEquipment->fk_carrier) > 0) { + if ($carrier->fk_panel > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php'; + $panel = new EquipmentPanel($db); + if ($panel->fetch($carrier->fk_panel) > 0) { + $anlageId = $panel->fk_anlage; + } + } else { + $anlageId = $carrier->fk_anlage; + } + } + $auditLog->logDuplicate($user, AuditLog::TYPE_EQUIPMENT, $newId, $newEquipment->label ?: $newEquipment->type_label, $sourceId, 0, $anlageId); + } + } else { + $response['error'] = $equipment->error ?: 'Duplication failed'; + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + case 'move': + // Move equipment to new position (for drag & drop) + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($equipment->fetch($equipmentId) > 0) { + $newPosition = GETPOSTINT('position_te'); + + $carrier = new EquipmentCarrier($db); + if ($carrier->fetch($equipment->fk_carrier) > 0) { + if ($carrier->isPositionAvailable($newPosition, $equipment->width_te, $equipmentId)) { + $equipment->position_te = $newPosition; + $result = $equipment->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $equipment->error; + } + } else { + $response['error'] = 'Position not available'; + } + } + } else { + $response['error'] = 'Equipment not found'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_carrier.php b/ajax/equipment_carrier.php new file mode 100755 index 0000000..14ba690 --- /dev/null +++ b/ajax/equipment_carrier.php @@ -0,0 +1,188 @@ + false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = 'Permission denied'; + echo json_encode($response); + exit; +} + +switch ($action) { + case 'get': + // Get single carrier data + if ($carrierId > 0 && $carrier->fetch($carrierId) > 0) { + $response['success'] = true; + $response['carrier'] = array( + 'id' => $carrier->id, + 'fk_anlage' => $carrier->fk_anlage, + 'fk_panel' => $carrier->fk_panel, + 'label' => $carrier->label, + 'total_te' => $carrier->total_te, + 'position' => $carrier->position, + 'panel_label' => $carrier->panel_label + ); + } else { + $response['error'] = 'Carrier not found'; + } + break; + + case 'list': + // List all carriers for an Anlage + if ($anlageId > 0) { + $carriers = $carrier->fetchByAnlage($anlageId); + $result = array(); + foreach ($carriers as $c) { + $c->fetchEquipment(); + $equipment = array(); + foreach ($c->equipment as $eq) { + $equipment[] = array( + 'id' => $eq->id, + 'type_id' => $eq->fk_equipment_type, + 'type_label' => $eq->type_label, + 'type_label_short' => $eq->type_label_short, + 'type_color' => $eq->type_color, + 'label' => $eq->label, + 'position_te' => $eq->position_te, + 'width_te' => $eq->width_te, + 'block_label' => $eq->getBlockLabel(), + 'block_color' => $eq->getBlockColor(), + 'field_values' => $eq->getFieldValues() + ); + } + $result[] = array( + 'id' => $c->id, + 'label' => $c->label, + 'total_te' => $c->total_te, + 'used_te' => $c->getUsedTE(), + 'free_te' => $c->getFreeTE(), + 'position' => $c->position, + 'equipment' => $equipment + ); + } + $response['success'] = true; + $response['carriers'] = $result; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'create': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + $carrier->fk_anlage = $anlageId; + $carrier->fk_panel = $panelId > 0 ? $panelId : null; + $carrier->label = GETPOST('label', 'alphanohtml'); + $carrier->total_te = GETPOSTINT('total_te') ?: 12; + + $result = $carrier->create($user); + if ($result > 0) { + $response['success'] = true; + $response['carrier_id'] = $result; + } else { + $response['error'] = $carrier->error; + } + break; + + case 'update': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($carrier->fetch($carrierId) > 0) { + $carrier->label = GETPOST('label', 'alphanohtml'); + $carrier->total_te = GETPOSTINT('total_te') ?: $carrier->total_te; + $carrier->position = GETPOSTINT('position'); + // Allow changing panel (0 or empty = no panel) + $newPanelId = GETPOSTINT('panel_id'); + $carrier->fk_panel = $newPanelId > 0 ? $newPanelId : null; + + $result = $carrier->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $carrier->error; + } + } else { + $response['error'] = 'Carrier not found'; + } + break; + + case 'delete': + if (!$user->hasRight('kundenkarte', 'delete')) { + $response['error'] = 'Permission denied'; + break; + } + if ($carrier->fetch($carrierId) > 0) { + $result = $carrier->delete($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $carrier->error; + } + } else { + $response['error'] = 'Carrier not found'; + } + break; + + case 'duplicate': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($carrier->fetch($carrierId) > 0) { + // Create a copy of the carrier + $newCarrier = new EquipmentCarrier($db); + $newCarrier->fk_anlage = $carrier->fk_anlage; + $newCarrier->fk_panel = $carrier->fk_panel; + $newCarrier->label = $carrier->label; + $newCarrier->total_te = $carrier->total_te; + $newCarrier->note_private = $carrier->note_private; + + $result = $newCarrier->create($user); + if ($result > 0) { + $response['success'] = true; + $response['carrier_id'] = $result; + } else { + $response['error'] = $newCarrier->error; + } + } else { + $response['error'] = 'Carrier not found'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_connection.php b/ajax/equipment_connection.php new file mode 100755 index 0000000..4b4332f --- /dev/null +++ b/ajax/equipment_connection.php @@ -0,0 +1,556 @@ + false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = 'Permission denied'; + echo json_encode($response); + exit; +} + +switch ($action) { + case 'get': + // Get single connection data + if ($connectionId > 0 && $connection->fetch($connectionId) > 0) { + $response['success'] = true; + $response['connection'] = array( + 'id' => $connection->id, + 'fk_source' => $connection->fk_source, + 'source_terminal' => $connection->source_terminal, + 'fk_target' => $connection->fk_target, + 'target_terminal' => $connection->target_terminal, + 'connection_type' => $connection->connection_type, + 'color' => $connection->color, + 'output_label' => $connection->output_label, + 'medium_type' => $connection->medium_type, + 'medium_spec' => $connection->medium_spec, + 'medium_length' => $connection->medium_length, + 'is_rail' => $connection->is_rail, + 'rail_start_te' => $connection->rail_start_te, + 'rail_end_te' => $connection->rail_end_te, + 'rail_phases' => $connection->rail_phases, + 'excluded_te' => $connection->excluded_te, + 'fk_carrier' => $connection->fk_carrier, + 'position_y' => $connection->position_y, + 'source_label' => $connection->source_label, + 'target_label' => $connection->target_label + ); + } else { + $response['error'] = 'Connection not found'; + } + break; + + case 'list': + // List all connections for a carrier + if ($carrierId > 0) { + $connections = $connection->fetchByCarrier($carrierId); + $result = array(); + foreach ($connections as $c) { + $result[] = array( + 'id' => $c->id, + 'fk_source' => $c->fk_source, + 'source_terminal' => $c->source_terminal, + 'source_label' => $c->source_label, + 'source_pos' => $c->source_pos, + 'source_width' => $c->source_width, + 'fk_target' => $c->fk_target, + 'target_terminal' => $c->target_terminal, + 'target_label' => $c->target_label, + 'target_pos' => $c->target_pos, + 'connection_type' => $c->connection_type, + 'color' => $c->getColor(), + 'output_label' => $c->output_label, + 'medium_type' => $c->medium_type, + 'medium_spec' => $c->medium_spec, + 'medium_length' => $c->medium_length, + 'is_rail' => $c->is_rail, + 'rail_start_te' => $c->rail_start_te, + 'rail_end_te' => $c->rail_end_te, + 'rail_phases' => $c->rail_phases, + 'excluded_te' => $c->excluded_te, + 'position_y' => $c->position_y, + 'display_label' => $c->getDisplayLabel() + ); + } + $response['success'] = true; + $response['connections'] = $result; + } else { + $response['error'] = 'Missing carrier_id'; + } + break; + + case 'list_outputs': + // List outputs for an equipment + if ($equipmentId > 0) { + $outputs = $connection->fetchOutputs($equipmentId); + $result = array(); + foreach ($outputs as $c) { + $result[] = array( + 'id' => $c->id, + 'connection_type' => $c->connection_type, + 'color' => $c->getColor(), + 'output_label' => $c->output_label, + 'medium_type' => $c->medium_type, + 'medium_spec' => $c->medium_spec, + 'medium_length' => $c->medium_length, + 'display_label' => $c->getDisplayLabel() + ); + } + $response['success'] = true; + $response['outputs'] = $result; + } else { + $response['error'] = 'Missing equipment_id'; + } + break; + + case 'create': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $connection->fk_source = GETPOSTINT('fk_source'); + $connection->source_terminal = GETPOST('source_terminal', 'alphanohtml') ?: 'output'; + $connection->source_terminal_id = GETPOST('source_terminal_id', 'alphanohtml'); + $connection->fk_target = GETPOSTINT('fk_target'); + $connection->target_terminal = GETPOST('target_terminal', 'alphanohtml') ?: 'input'; + $connection->target_terminal_id = GETPOST('target_terminal_id', 'alphanohtml'); + $connection->connection_type = GETPOST('connection_type', 'alphanohtml'); + $connection->color = GETPOST('color', 'alphanohtml'); + $connection->output_label = GETPOST('output_label', 'alphanohtml'); + $connection->medium_type = GETPOST('medium_type', 'alphanohtml'); + $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + $connection->is_rail = GETPOSTINT('is_rail'); + $connection->rail_start_te = GETPOSTINT('rail_start_te'); + $connection->rail_end_te = GETPOSTINT('rail_end_te'); + $connection->fk_carrier = $carrierId; + $connection->position_y = GETPOSTINT('position_y'); + $connection->path_data = GETPOST('path_data', 'nohtml'); + + $result = $connection->create($user); + if ($result > 0) { + $response['success'] = true; + $response['connection_id'] = $result; + } else { + $response['error'] = $connection->error ?: 'Create failed'; + } + break; + + case 'update': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($connection->fetch($connectionId) > 0) { + // Only update fields that are actually sent (preserve existing values) + if (GETPOSTISSET('fk_source')) $connection->fk_source = GETPOSTINT('fk_source'); + if (GETPOSTISSET('source_terminal')) $connection->source_terminal = GETPOST('source_terminal', 'alphanohtml') ?: $connection->source_terminal; + if (GETPOSTISSET('fk_target')) $connection->fk_target = GETPOSTINT('fk_target'); + if (GETPOSTISSET('target_terminal')) $connection->target_terminal = GETPOST('target_terminal', 'alphanohtml') ?: $connection->target_terminal; + if (GETPOSTISSET('connection_type')) $connection->connection_type = GETPOST('connection_type', 'alphanohtml'); + if (GETPOSTISSET('color')) $connection->color = GETPOST('color', 'alphanohtml'); + if (GETPOSTISSET('output_label')) $connection->output_label = GETPOST('output_label', 'alphanohtml'); + if (GETPOSTISSET('medium_type')) $connection->medium_type = GETPOST('medium_type', 'alphanohtml'); + if (GETPOSTISSET('medium_spec')) $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + if (GETPOSTISSET('medium_length')) $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + if (GETPOSTISSET('is_rail')) $connection->is_rail = GETPOSTINT('is_rail'); + if (GETPOSTISSET('rail_start_te')) $connection->rail_start_te = GETPOSTINT('rail_start_te'); + if (GETPOSTISSET('rail_end_te')) $connection->rail_end_te = GETPOSTINT('rail_end_te'); + if (GETPOSTISSET('rail_phases')) $connection->rail_phases = GETPOST('rail_phases', 'alphanohtml'); + if (GETPOSTISSET('excluded_te')) $connection->excluded_te = GETPOST('excluded_te', 'alphanohtml'); + if (GETPOSTISSET('position_y')) $connection->position_y = GETPOSTINT('position_y'); + if (GETPOSTISSET('path_data')) $connection->path_data = GETPOST('path_data', 'nohtml'); + + $result = $connection->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $connection->error ?: 'Update failed'; + } + } else { + $response['error'] = 'Connection not found'; + } + break; + + case 'delete': + if (!$user->hasRight('kundenkarte', 'delete')) { + $response['error'] = 'Permission denied'; + break; + } + if ($connection->fetch($connectionId) > 0) { + $result = $connection->delete($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $connection->error ?: 'Delete failed'; + } + } else { + $response['error'] = 'Connection not found'; + } + break; + + case 'create_rail': + // Create a rail/bar connection spanning multiple equipment + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $connection->is_rail = 1; + $connection->connection_type = GETPOST('connection_type', 'alphanohtml'); + $connection->color = GETPOST('color', 'alphanohtml'); + $connection->rail_start_te = GETPOSTINT('rail_start_te'); + $connection->rail_end_te = GETPOSTINT('rail_end_te'); + $connection->rail_phases = GETPOST('rail_phases', 'alphanohtml'); + $connection->excluded_te = GETPOST('excluded_te', 'alphanohtml'); + $connection->fk_carrier = $carrierId; + $connection->position_y = GETPOSTINT('position_y'); + + $result = $connection->create($user); + if ($result > 0) { + $response['success'] = true; + $response['connection_id'] = $result; + } else { + $response['error'] = $connection->error ?: 'Create failed'; + } + break; + + case 'update_rail_position': + // Update rail/busbar start and end position (for drag & drop) + // Also supports moving to a different carrier (different panel/hutschiene) + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($connection->fetch($connectionId) > 0) { + // Only allow updating rail connections + if (!$connection->is_rail) { + $response['error'] = 'Not a rail connection'; + break; + } + + $connection->rail_start_te = GETPOSTINT('rail_start_te'); + $connection->rail_end_te = GETPOSTINT('rail_end_te'); + + // Update carrier if provided (for moving between panels) + if (GETPOSTISSET('carrier_id') && GETPOSTINT('carrier_id') > 0) { + $connection->fk_carrier = GETPOSTINT('carrier_id'); + } + + $result = $connection->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $connection->error ?: 'Update failed'; + } + } else { + $response['error'] = 'Connection not found'; + } + break; + + case 'create_output': + // Create an output connection + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $connection->fk_source = $equipmentId; + $connection->source_terminal = 'output'; + $connection->fk_target = null; + $connection->connection_type = GETPOST('connection_type', 'alphanohtml'); + $connection->color = GETPOST('color', 'alphanohtml'); + $connection->output_label = GETPOST('output_label', 'alphanohtml'); + $connection->medium_type = GETPOST('medium_type', 'alphanohtml'); + $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + $connection->fk_carrier = $carrierId; + $connection->position_y = 0; + + $result = $connection->create($user); + if ($result > 0) { + $response['success'] = true; + $response['connection_id'] = $result; + } else { + $response['error'] = $connection->error ?: 'Create failed'; + } + break; + + case 'list_all': + // List all connections for an anlage (across all carriers) + $anlageId = GETPOSTINT('anlage_id'); + if ($anlageId > 0) { + // Get all carriers for this anlage + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; + $carrierObj = new EquipmentCarrier($db); + $carriers = $carrierObj->fetchByAnlage($anlageId); + $carrierIds = array(); + foreach ($carriers as $carrier) { + $carrierIds[] = (int)$carrier->id; + } + + $allConnections = array(); + + if (!empty($carrierIds)) { + // Find all connections where source OR target equipment belongs to this anlage's carriers + // This includes connections with fk_carrier=NULL + $sql = "SELECT DISTINCT c.*, + se.label as source_label, se.position_te as source_pos, se.width_te as source_width, + te.label as target_label, te.position_te as target_pos + FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection c + LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment se ON c.fk_source = se.rowid + LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment te ON c.fk_target = te.rowid + WHERE (c.fk_carrier IN (".implode(',', $carrierIds).") + OR se.fk_carrier IN (".implode(',', $carrierIds).") + OR te.fk_carrier IN (".implode(',', $carrierIds).")) + AND c.status = 1"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $allConnections[] = array( + 'id' => $obj->rowid, + 'fk_source' => $obj->fk_source, + 'source_terminal' => $obj->source_terminal, + 'source_terminal_id' => $obj->source_terminal_id, + 'source_label' => $obj->source_label, + 'source_pos' => $obj->source_pos, + 'source_width' => $obj->source_width, + 'fk_target' => $obj->fk_target, + 'target_terminal' => $obj->target_terminal, + 'target_terminal_id' => $obj->target_terminal_id, + 'target_label' => $obj->target_label, + 'target_pos' => $obj->target_pos, + 'connection_type' => $obj->connection_type, + 'color' => $obj->color ?: '#3498db', + 'output_label' => $obj->output_label, + 'medium_type' => $obj->medium_type, + 'medium_spec' => $obj->medium_spec, + 'medium_length' => $obj->medium_length, + 'is_rail' => $obj->is_rail, + 'rail_start_te' => $obj->rail_start_te, + 'rail_end_te' => $obj->rail_end_te, + 'rail_phases' => $obj->rail_phases, + 'position_y' => $obj->position_y, + 'fk_carrier' => $obj->fk_carrier, + 'path_data' => isset($obj->path_data) ? $obj->path_data : null + ); + } + $db->free($resql); + } + } + + $response['success'] = true; + $response['connections'] = $allConnections; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'clear_all': + // Delete all connections for an anlage + if (!$user->hasRight('kundenkarte', 'delete')) { + $response['error'] = 'Permission denied'; + break; + } + + $anlageId = GETPOSTINT('anlage_id'); + if ($anlageId > 0) { + // Get all carriers for this anlage + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; + $carrierObj = new EquipmentCarrier($db); + $carriers = $carrierObj->fetchByAnlage($anlageId); + + $deletedCount = 0; + foreach ($carriers as $carrier) { + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection WHERE fk_carrier = ".((int)$carrier->id); + $resql = $db->query($sql); + if ($resql) { + $deletedCount += $db->affected_rows($resql); + } + } + + $response['success'] = true; + $response['deleted_count'] = $deletedCount; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + // ============================================ + // Bridge Actions (Brücken zwischen Klemmen) + // ============================================ + + case 'list_bridges': + // List all bridges for an anlage + $anlageId = GETPOSTINT('anlage_id'); + if ($anlageId > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/terminalbridge.class.php'; + $bridgeObj = new TerminalBridge($db); + $bridges = $bridgeObj->fetchAllByAnlage($anlageId); + + $bridgeList = array(); + foreach ($bridges as $bridge) { + $bridgeList[] = array( + 'id' => $bridge->id, + 'fk_carrier' => $bridge->fk_carrier, + 'start_te' => $bridge->start_te, + 'end_te' => $bridge->end_te, + 'terminal_side' => $bridge->terminal_side, + 'terminal_row' => $bridge->terminal_row, + 'color' => $bridge->color, + 'bridge_type' => $bridge->bridge_type, + 'label' => $bridge->label + ); + } + + $response['success'] = true; + $response['bridges'] = $bridgeList; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'create_bridge': + // Create a new terminal bridge + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $anlageId = GETPOSTINT('anlage_id'); + $carrierId = GETPOSTINT('carrier_id'); + $startTE = GETPOSTINT('start_te'); + $endTE = GETPOSTINT('end_te'); + + if (empty($anlageId) || empty($carrierId) || empty($startTE) || empty($endTE)) { + $response['error'] = 'Missing required parameters'; + break; + } + + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/terminalbridge.class.php'; + $bridge = new TerminalBridge($db); + $bridge->fk_anlage = $anlageId; + $bridge->fk_carrier = $carrierId; + $bridge->start_te = min($startTE, $endTE); + $bridge->end_te = max($startTE, $endTE); + $bridge->terminal_side = GETPOST('terminal_side', 'alpha') ?: 'top'; + $bridge->terminal_row = GETPOSTINT('terminal_row'); + $bridge->color = GETPOST('color', 'alphanohtml') ?: '#e74c3c'; + $bridge->bridge_type = GETPOST('bridge_type', 'alpha') ?: 'standard'; + $bridge->label = GETPOST('label', 'alphanohtml'); + + $result = $bridge->create($user); + if ($result > 0) { + $response['success'] = true; + $response['bridge_id'] = $result; + $response['bridge'] = array( + 'id' => $bridge->id, + 'fk_carrier' => $bridge->fk_carrier, + 'start_te' => $bridge->start_te, + 'end_te' => $bridge->end_te, + 'terminal_side' => $bridge->terminal_side, + 'terminal_row' => $bridge->terminal_row, + 'color' => $bridge->color, + 'bridge_type' => $bridge->bridge_type, + 'label' => $bridge->label + ); + } else { + $response['error'] = $bridge->error ?: 'Create failed'; + } + break; + + case 'update_bridge': + // Update an existing bridge + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $bridgeId = GETPOSTINT('bridge_id'); + if ($bridgeId > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/terminalbridge.class.php'; + $bridge = new TerminalBridge($db); + if ($bridge->fetch($bridgeId) > 0) { + if (GETPOSTISSET('start_te')) $bridge->start_te = GETPOSTINT('start_te'); + if (GETPOSTISSET('end_te')) $bridge->end_te = GETPOSTINT('end_te'); + if (GETPOSTISSET('terminal_side')) $bridge->terminal_side = GETPOST('terminal_side', 'alpha'); + if (GETPOSTISSET('terminal_row')) $bridge->terminal_row = GETPOSTINT('terminal_row'); + if (GETPOSTISSET('color')) $bridge->color = GETPOST('color', 'alphanohtml'); + if (GETPOSTISSET('bridge_type')) $bridge->bridge_type = GETPOST('bridge_type', 'alpha'); + if (GETPOSTISSET('label')) $bridge->label = GETPOST('label', 'alphanohtml'); + + $result = $bridge->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $bridge->error ?: 'Update failed'; + } + } else { + $response['error'] = 'Bridge not found'; + } + } else { + $response['error'] = 'Missing bridge_id'; + } + break; + + case 'delete_bridge': + // Delete a bridge + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $bridgeId = GETPOSTINT('bridge_id'); + if ($bridgeId > 0) { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/terminalbridge.class.php'; + $bridge = new TerminalBridge($db); + if ($bridge->fetch($bridgeId) > 0) { + $result = $bridge->delete($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $bridge->error ?: 'Delete failed'; + } + } else { + $response['error'] = 'Bridge not found'; + } + } else { + $response['error'] = 'Missing bridge_id'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_panel.php b/ajax/equipment_panel.php new file mode 100755 index 0000000..acde20a --- /dev/null +++ b/ajax/equipment_panel.php @@ -0,0 +1,201 @@ + false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = 'Permission denied'; + echo json_encode($response); + exit; +} + +switch ($action) { + case 'get': + // Get single panel data + if ($panelId > 0 && $panel->fetch($panelId) > 0) { + $response['success'] = true; + $response['panel'] = array( + 'id' => $panel->id, + 'fk_anlage' => $panel->fk_anlage, + 'label' => $panel->label, + 'position' => $panel->position, + 'note_private' => $panel->note_private, + 'status' => $panel->status + ); + } else { + $response['error'] = 'Panel not found'; + } + break; + + case 'list': + // List all panels for an Anlage + if ($anlageId > 0) { + $panels = $panel->fetchByAnlage($anlageId); + $result = array(); + foreach ($panels as $p) { + $result[] = array( + 'id' => $p->id, + 'fk_anlage' => $p->fk_anlage, + 'label' => $p->label, + 'position' => $p->position, + 'status' => $p->status + ); + } + $response['success'] = true; + $response['panels'] = $result; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'create': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + + $panel->fk_anlage = $anlageId; + $panel->label = GETPOST('label', 'alphanohtml'); + $panel->position = GETPOSTINT('position'); + $panel->note_private = GETPOST('note_private', 'restricthtml'); + + $result = $panel->create($user); + if ($result > 0) { + $response['success'] = true; + $response['panel_id'] = $result; + $response['label'] = $panel->label; + } else { + $response['error'] = $panel->error; + } + break; + + case 'update': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($panel->fetch($panelId) > 0) { + $panel->label = GETPOST('label', 'alphanohtml') ?: $panel->label; + $panel->position = GETPOSTINT('position') ?: $panel->position; + $panel->note_private = GETPOST('note_private', 'restricthtml'); + + $result = $panel->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $panel->error; + } + } else { + $response['error'] = 'Panel not found'; + } + break; + + case 'delete': + if (!$user->hasRight('kundenkarte', 'delete')) { + $response['error'] = 'Permission denied'; + break; + } + if ($panel->fetch($panelId) > 0) { + $result = $panel->delete($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = $panel->error; + } + } else { + $response['error'] = 'Panel not found'; + } + break; + + case 'list_with_carriers': + // List all panels with their carriers for an Anlage + if ($anlageId > 0) { + $panels = $panel->fetchByAnlage($anlageId); + $result = array(); + + foreach ($panels as $p) { + $panelData = array( + 'id' => $p->id, + 'fk_anlage' => $p->fk_anlage, + 'label' => $p->label, + 'position' => $p->position, + 'status' => $p->status, + 'carriers' => array() + ); + + // Fetch carriers for this panel + $p->fetchCarriers(); + foreach ($p->carriers as $c) { + $panelData['carriers'][] = array( + 'id' => $c->id, + 'label' => $c->label, + 'total_te' => $c->total_te, + 'position' => $c->position + ); + } + + $result[] = $panelData; + } + + $response['success'] = true; + $response['panels'] = $result; + } else { + $response['error'] = 'Missing anlage_id'; + } + break; + + case 'duplicate': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if ($panel->fetch($panelId) > 0) { + // Create a copy of the panel + $newPanel = new EquipmentPanel($db); + $newPanel->fk_anlage = $panel->fk_anlage; + $newPanel->label = $panel->label.' (Kopie)'; + $newPanel->note_private = $panel->note_private; + + $result = $newPanel->create($user); + if ($result > 0) { + $response['success'] = true; + $response['panel_id'] = $result; + } else { + $response['error'] = $newPanel->error; + } + } else { + $response['error'] = 'Panel not found'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_type_block_image.php b/ajax/equipment_type_block_image.php new file mode 100755 index 0000000..496e5f7 --- /dev/null +++ b/ajax/equipment_type_block_image.php @@ -0,0 +1,190 @@ +admin && !$user->hasRight('kundenkarte', 'admin')) { + echo json_encode(array('success' => false, 'error' => 'Access denied')); + exit; +} + +$action = GETPOST('action', 'aZ09'); +$typeId = GETPOSTINT('type_id'); + +$response = array('success' => false); + +// Directory for block images +$uploadDir = DOL_DATA_ROOT.'/kundenkarte/block_images/'; + +// Create directory if not exists +if (!is_dir($uploadDir)) { + dol_mkdir($uploadDir); +} + +switch ($action) { + case 'upload': + if (empty($_FILES['block_image']) || $_FILES['block_image']['error'] !== UPLOAD_ERR_OK) { + $response['error'] = 'No file uploaded or upload error'; + break; + } + + $file = $_FILES['block_image']; + $fileName = dol_sanitizeFileName($file['name']); + $fileExt = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + + // Validate file type + $allowedExtensions = array('svg', 'png', 'jpg', 'jpeg', 'gif', 'webp'); + if (!in_array($fileExt, $allowedExtensions)) { + $response['error'] = 'Invalid file type. Only SVG, PNG, JPG, GIF, WEBP are allowed.'; + break; + } + + // Validate MIME type + $mimeType = mime_content_type($file['tmp_name']); + $allowedMimes = array('image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'text/plain', 'text/xml', 'application/xml'); + if (!in_array($mimeType, $allowedMimes)) { + $response['error'] = 'Invalid MIME type: '.$mimeType; + break; + } + + // For SVG files, do basic security check + if ($fileExt === 'svg') { + $content = file_get_contents($file['tmp_name']); + // Check for potentially dangerous content + $dangerous = array('fetch($typeId) > 0) { + // Delete old block image file if exists + if ($equipmentType->block_image && file_exists($uploadDir.$equipmentType->block_image)) { + unlink($uploadDir.$equipmentType->block_image); + } + + $equipmentType->block_image = $newFileName; + $result = $equipmentType->update($user); + + if ($result > 0) { + $response['success'] = true; + $response['block_image'] = $newFileName; + $response['block_image_url'] = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.$newFileName; + } else { + $response['error'] = 'Database update failed'; + // Remove uploaded file on DB error + unlink($destPath); + } + } else { + $response['error'] = 'Equipment type not found'; + unlink($destPath); + } + } else { + $response['error'] = 'Failed to move uploaded file'; + } + break; + + case 'delete': + $equipmentType = new EquipmentType($db); + if ($equipmentType->fetch($typeId) > 0) { + if ($equipmentType->block_image && file_exists($uploadDir.$equipmentType->block_image)) { + unlink($uploadDir.$equipmentType->block_image); + } + $equipmentType->block_image = ''; + $result = $equipmentType->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = 'Database update failed'; + } + } else { + $response['error'] = 'Equipment type not found'; + } + break; + + case 'get': + $equipmentType = new EquipmentType($db); + if ($equipmentType->fetch($typeId) > 0) { + $response['success'] = true; + $response['block_image'] = $equipmentType->block_image; + if ($equipmentType->block_image) { + $response['block_image_url'] = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.$equipmentType->block_image; + } + } else { + $response['error'] = 'Equipment type not found'; + } + break; + + case 'select': + // Select an existing image from the block_images folder + $selectedImage = GETPOST('image', 'alphanohtml'); + if (empty($selectedImage)) { + $response['error'] = 'No image selected'; + break; + } + + // Path-Traversal-Schutz: nur Dateiname ohne Verzeichnisanteile + $selectedImage = basename($selectedImage); + + // Validate that the image exists + $imagePath = $uploadDir . $selectedImage; + if (!file_exists($imagePath)) { + $response['error'] = 'Image file not found'; + break; + } + + // Validate file extension + $fileExt = strtolower(pathinfo($selectedImage, PATHINFO_EXTENSION)); + $allowedExtensions = array('svg', 'png', 'jpg', 'jpeg', 'gif', 'webp'); + if (!in_array($fileExt, $allowedExtensions)) { + $response['error'] = 'Invalid file type'; + break; + } + + $equipmentType = new EquipmentType($db); + if ($equipmentType->fetch($typeId) > 0) { + $equipmentType->block_image = $selectedImage; + $result = $equipmentType->update($user); + if ($result > 0) { + $response['success'] = true; + $response['block_image'] = $selectedImage; + $response['block_image_url'] = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.urlencode($selectedImage); + } else { + $response['error'] = 'Database update failed'; + } + } else { + $response['error'] = 'Equipment type not found'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_type_fields.php b/ajax/equipment_type_fields.php new file mode 100755 index 0000000..e1c2e2b --- /dev/null +++ b/ajax/equipment_type_fields.php @@ -0,0 +1,74 @@ + false, 'fields' => array()); + +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = 'Permission denied'; + echo json_encode($response); + exit; +} + +if ($typeId > 0) { + $type = new EquipmentType($db); + if ($type->fetch($typeId) > 0) { + $fields = $type->fetchFields(1); + + // Get existing values if editing + $existingValues = array(); + if ($equipmentId > 0) { + $equipment = new Equipment($db); + if ($equipment->fetch($equipmentId) > 0) { + $existingValues = $equipment->getFieldValues(); + } + } + + $result = array(); + foreach ($fields as $field) { + $value = isset($existingValues[$field->field_code]) ? $existingValues[$field->field_code] : $field->field_default; + $result[] = array( + 'code' => $field->field_code, + 'label' => $field->field_label, + 'type' => $field->field_type, + 'options' => $field->field_options, + 'required' => $field->required, + 'show_on_block' => $field->show_on_block, + 'value' => $value + ); + } + + $response['success'] = true; + $response['fields'] = $result; + $response['type'] = array( + 'id' => $type->id, + 'label' => $type->label, + 'label_short' => $type->label_short, + 'width_te' => $type->width_te, + 'color' => $type->color + ); + } +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/equipment_type_icon.php b/ajax/equipment_type_icon.php new file mode 100755 index 0000000..bb49998 --- /dev/null +++ b/ajax/equipment_type_icon.php @@ -0,0 +1,148 @@ +admin && !$user->hasRight('kundenkarte', 'admin')) { + echo json_encode(array('success' => false, 'error' => 'Access denied')); + exit; +} + +$action = GETPOST('action', 'aZ09'); +$typeId = GETPOSTINT('type_id'); + +$response = array('success' => false); + +// Directory for equipment type icons +$uploadDir = DOL_DATA_ROOT.'/kundenkarte/equipment_icons/'; + +// Create directory if not exists +if (!is_dir($uploadDir)) { + dol_mkdir($uploadDir); +} + +switch ($action) { + case 'upload': + if (empty($_FILES['icon_file']) || $_FILES['icon_file']['error'] !== UPLOAD_ERR_OK) { + $response['error'] = 'No file uploaded or upload error'; + break; + } + + $file = $_FILES['icon_file']; + $fileName = dol_sanitizeFileName($file['name']); + $fileExt = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + + // Validate file type + $allowedExtensions = array('svg', 'png'); + if (!in_array($fileExt, $allowedExtensions)) { + $response['error'] = 'Invalid file type. Only SVG and PNG are allowed.'; + break; + } + + // Validate MIME type + $mimeType = mime_content_type($file['tmp_name']); + $allowedMimes = array('image/svg+xml', 'image/png', 'text/plain', 'text/xml', 'application/xml'); + if (!in_array($mimeType, $allowedMimes)) { + $response['error'] = 'Invalid MIME type: '.$mimeType; + break; + } + + // For SVG files, do basic security check + if ($fileExt === 'svg') { + $content = file_get_contents($file['tmp_name']); + // Check for potentially dangerous content + $dangerous = array('fetch($typeId) > 0) { + // Delete old icon file if exists + if ($equipmentType->icon_file && file_exists($uploadDir.$equipmentType->icon_file)) { + unlink($uploadDir.$equipmentType->icon_file); + } + + $equipmentType->icon_file = $newFileName; + $result = $equipmentType->update($user); + + if ($result > 0) { + $response['success'] = true; + $response['icon_file'] = $newFileName; + $response['icon_url'] = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=equipment_icons/'.$newFileName; + } else { + $response['error'] = 'Database update failed'; + // Remove uploaded file on DB error + unlink($destPath); + } + } else { + $response['error'] = 'Equipment type not found'; + unlink($destPath); + } + } else { + $response['error'] = 'Failed to move uploaded file'; + } + break; + + case 'delete': + $equipmentType = new EquipmentType($db); + if ($equipmentType->fetch($typeId) > 0) { + if ($equipmentType->icon_file && file_exists($uploadDir.$equipmentType->icon_file)) { + unlink($uploadDir.$equipmentType->icon_file); + } + $equipmentType->icon_file = ''; + $result = $equipmentType->update($user); + if ($result > 0) { + $response['success'] = true; + } else { + $response['error'] = 'Database update failed'; + } + } else { + $response['error'] = 'Equipment type not found'; + } + break; + + case 'get': + $equipmentType = new EquipmentType($db); + if ($equipmentType->fetch($typeId) > 0) { + $response['success'] = true; + $response['icon_file'] = $equipmentType->icon_file; + if ($equipmentType->icon_file) { + $response['icon_url'] = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=equipment_icons/'.$equipmentType->icon_file; + } + } else { + $response['error'] = 'Equipment type not found'; + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/export_schematic_pdf.php b/ajax/export_schematic_pdf.php new file mode 100755 index 0000000..077ed74 --- /dev/null +++ b/ajax/export_schematic_pdf.php @@ -0,0 +1,717 @@ +loadLangs(array('companies', 'kundenkarte@kundenkarte')); + +// Get parameters +$anlageId = GETPOSTINT('anlage_id'); +$svgContent = GETPOST('svg_content', 'restricthtml'); +$format = GETPOST('format', 'alpha') ?: 'A4'; +$orientation = GETPOST('orientation', 'alpha') ?: 'L'; // L=Landscape, P=Portrait + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + accessforbidden(); +} + +// Load Anlage data +$anlage = new Anlage($db); +if ($anlage->fetch($anlageId) <= 0) { + die('Anlage not found'); +} + +// Load company +$societe = new Societe($db); +$societe->fetch($anlage->fk_soc); + +// Load carriers for this anlage +$carrier = new EquipmentCarrier($db); +$carriers = $carrier->fetchByAnlage($anlageId); + +// Load equipment +$equipment = new Equipment($db); +$equipmentList = array(); +foreach ($carriers as $c) { + $eqList = $equipment->fetchByCarrier($c->id); + $equipmentList = array_merge($equipmentList, $eqList); +} + +// Load connections +$connection = new EquipmentConnection($db); +$connections = array(); +foreach ($carriers as $c) { + $connList = $connection->fetchByCarrier($c->id); + $connections = array_merge($connections, $connList); +} + +// Create PDF - Landscape A3 or A4 for schematic +$pdf = pdf_getInstance(); +$pdf->SetCreator('Dolibarr - Kundenkarte Schaltplan'); +$pdf->SetAuthor($user->getFullName($langs)); +$pdf->SetTitle('Leitungslaufplan - '.$anlage->label); + +// Page format +if ($format == 'A3') { + $pageWidth = 420; + $pageHeight = 297; +} else { + $pageWidth = 297; + $pageHeight = 210; +} + +if ($orientation == 'P') { + $tmp = $pageWidth; + $pageWidth = $pageHeight; + $pageHeight = $tmp; +} + +$pdf->SetMargins(10, 10, 10); +$pdf->SetAutoPageBreak(false); +$pdf->AddPage($orientation, array($pageWidth, $pageHeight)); + +// ============================================ +// DIN EN 61082 / ISO 7200 Title Block (Schriftfeld) +// Position: Bottom right corner +// ============================================ + +$titleBlockWidth = 180; +$titleBlockHeight = 56; +$titleBlockX = $pageWidth - $titleBlockWidth - 10; +$titleBlockY = $pageHeight - $titleBlockHeight - 10; + +// Draw title block frame +$pdf->SetDrawColor(0, 0, 0); +$pdf->SetLineWidth(0.5); +$pdf->Rect($titleBlockX, $titleBlockY, $titleBlockWidth, $titleBlockHeight); + +// Title block grid - following DIN structure +// Row heights from bottom: 8, 8, 8, 8, 8, 8, 8 = 56mm total +$rowHeight = 8; +$rows = 7; + +// Column widths: 30 | 50 | 50 | 50 = 180mm +$col1 = 30; // Labels +$col2 = 50; // Company info +$col3 = 50; // Document info +$col4 = 50; // Revision info + +// Draw horizontal lines +for ($i = 1; $i < $rows; $i++) { + $y = $titleBlockY + ($i * $rowHeight); + $pdf->Line($titleBlockX, $y, $titleBlockX + $titleBlockWidth, $y); +} + +// Draw vertical lines +$pdf->Line($titleBlockX + $col1, $titleBlockY, $titleBlockX + $col1, $titleBlockY + $titleBlockHeight); +$pdf->Line($titleBlockX + $col1 + $col2, $titleBlockY, $titleBlockX + $col1 + $col2, $titleBlockY + $titleBlockHeight); +$pdf->Line($titleBlockX + $col1 + $col2 + $col3, $titleBlockY, $titleBlockX + $col1 + $col2 + $col3, $titleBlockY + $titleBlockHeight); + +// Fill in title block content +$pdf->SetFont('dejavusans', '', 6); +$pdf->SetTextColor(0, 0, 0); + +// Row 1 (from top): Document title spanning full width +$pdf->SetFont('dejavusans', 'B', 12); +$pdf->SetXY($titleBlockX + 2, $titleBlockY + 1); +$pdf->Cell($titleBlockWidth - 4, $rowHeight - 2, 'LEITUNGSLAUFPLAN', 0, 0, 'C'); + +// Row 2: Installation name +$pdf->SetFont('dejavusans', 'B', 10); +$pdf->SetXY($titleBlockX + 2, $titleBlockY + $rowHeight + 1); +$pdf->Cell($titleBlockWidth - 4, $rowHeight - 2, $anlage->label, 0, 0, 'C'); + +// Row 3: Labels +$pdf->SetFont('dejavusans', '', 6); +$y = $titleBlockY + (2 * $rowHeight); +$pdf->SetXY($titleBlockX + 1, $y + 1); +$pdf->Cell($col1 - 2, 3, 'Erstellt', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 1); +$pdf->Cell($col2 - 2, 3, 'Kunde', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 1); +$pdf->Cell($col3 - 2, 3, 'Projekt-Nr.', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 1); +$pdf->Cell($col4 - 2, 3, 'Blatt', 0, 0, 'L'); + +// Row 3: Values +$pdf->SetFont('dejavusans', '', 8); +$pdf->SetXY($titleBlockX + 1, $y + 4); +$pdf->Cell($col1 - 2, 4, dol_print_date(dol_now(), 'day'), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 4); +$pdf->Cell($col2 - 2, 4, dol_trunc($societe->name, 25), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 4); +$pdf->Cell($col3 - 2, 4, $anlage->ref ?: '-', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 4); +$pdf->Cell($col4 - 2, 4, '1 / 1', 0, 0, 'L'); + +// Row 4: More labels +$y = $titleBlockY + (3 * $rowHeight); +$pdf->SetFont('dejavusans', '', 6); +$pdf->SetXY($titleBlockX + 1, $y + 1); +$pdf->Cell($col1 - 2, 3, 'Bearbeiter', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 1); +$pdf->Cell($col2 - 2, 3, 'Adresse', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 1); +$pdf->Cell($col3 - 2, 3, 'Anlage', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 1); +$pdf->Cell($col4 - 2, 3, 'Revision', 0, 0, 'L'); + +// Row 4: Values +$pdf->SetFont('dejavusans', '', 8); +$pdf->SetXY($titleBlockX + 1, $y + 4); +$pdf->Cell($col1 - 2, 4, dol_trunc($user->getFullName($langs), 15), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 4); +$address = trim($societe->address.' '.$societe->zip.' '.$societe->town); +$pdf->Cell($col2 - 2, 4, dol_trunc($address, 25), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 4); +$pdf->Cell($col3 - 2, 4, $anlage->type_label ?: '-', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 4); +$pdf->Cell($col4 - 2, 4, 'A', 0, 0, 'L'); + +// Row 5: Equipment count +$y = $titleBlockY + (4 * $rowHeight); +$pdf->SetFont('dejavusans', '', 6); +$pdf->SetXY($titleBlockX + 1, $y + 1); +$pdf->Cell($col1 - 2, 3, 'Komponenten', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 1); +$pdf->Cell($col2 - 2, 3, 'Verbindungen', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 1); +$pdf->Cell($col3 - 2, 3, 'Hutschienen', 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 1); +$pdf->Cell($col4 - 2, 3, 'Format', 0, 0, 'L'); + +$pdf->SetFont('dejavusans', '', 8); +$pdf->SetXY($titleBlockX + 1, $y + 4); +$pdf->Cell($col1 - 2, 4, count($equipmentList), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + 1, $y + 4); +$pdf->Cell($col2 - 2, 4, count($connections), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + 1, $y + 4); +$pdf->Cell($col3 - 2, 4, count($carriers), 0, 0, 'L'); +$pdf->SetXY($titleBlockX + $col1 + $col2 + $col3 + 1, $y + 4); +$pdf->Cell($col4 - 2, 4, $format.' '.$orientation, 0, 0, 'L'); + +// Row 6: Norm reference +$y = $titleBlockY + (5 * $rowHeight); +$pdf->SetFont('dejavusans', '', 6); +$pdf->SetXY($titleBlockX + 1, $y + 2); +$pdf->Cell($titleBlockWidth - 2, 4, 'Erstellt nach DIN EN 61082 / DIN EN 81346', 0, 0, 'C'); + +// Row 7: Company info +$y = $titleBlockY + (6 * $rowHeight); +$pdf->SetFont('dejavusans', 'B', 7); +$pdf->SetXY($titleBlockX + 1, $y + 2); +$pdf->Cell($titleBlockWidth - 2, 4, $mysoc->name, 0, 0, 'C'); + +// ============================================ +// Draw the Schematic Content Area +// ============================================ + +$schematicX = 10; +$schematicY = 10; +$schematicWidth = $pageWidth - 20; +$schematicHeight = $titleBlockY - 15; + +// Draw frame around schematic area +$pdf->SetDrawColor(0, 0, 0); +$pdf->SetLineWidth(0.3); +$pdf->Rect($schematicX, $schematicY, $schematicWidth, $schematicHeight); + +// If SVG content provided, embed it +if (!empty($svgContent)) { + // Clean SVG for TCPDF + $svgContent = preg_replace('/<\?xml[^>]*\?>/', '', $svgContent); + $svgContent = preg_replace('/]*>/', '', $svgContent); + + // Try to embed SVG + try { + // Scale SVG to fit in schematic area + $pdf->ImageSVG('@'.$svgContent, $schematicX + 2, $schematicY + 2, $schematicWidth - 4, $schematicHeight - 4, '', '', '', 0, false); + } catch (Exception $e) { + // SVG embedding failed - draw placeholder + $pdf->SetFont('dejavusans', 'I', 10); + $pdf->SetXY($schematicX + 10, $schematicY + 10); + $pdf->Cell(0, 10, 'SVG konnte nicht eingebettet werden: '.$e->getMessage(), 0, 1); + } +} else { + // Draw schematic manually if no SVG provided + drawSchematicContent($pdf, $carriers, $equipmentList, $connections, $schematicX, $schematicY, $schematicWidth, $schematicHeight); +} + +// ============================================ +// Add Wiring List on second page (Verdrahtungsliste) +// ============================================ + +if (count($connections) > 0) { + $pdf->AddPage($orientation, array($pageWidth, $pageHeight)); + + // Title + $pdf->SetFont('dejavusans', 'B', 14); + $pdf->SetXY(10, 10); + $pdf->Cell(0, 8, 'VERDRAHTUNGSLISTE / KLEMMENPLAN', 0, 1, 'L'); + + $pdf->SetFont('dejavusans', '', 9); + $pdf->SetXY(10, 18); + $pdf->Cell(0, 5, $anlage->label.' - '.$societe->name, 0, 1, 'L'); + + // Table header + $pdf->SetY(28); + $pdf->SetFont('dejavusans', 'B', 8); + $pdf->SetFillColor(220, 220, 220); + + $colWidths = array(15, 35, 25, 35, 25, 25, 30, 30); + $headers = array('Nr.', 'Von (Quelle)', 'Klemme', 'Nach (Ziel)', 'Klemme', 'Typ', 'Leitung', 'Bemerkung'); + + $x = 10; + for ($i = 0; $i < count($headers); $i++) { + $pdf->SetXY($x, 28); + $pdf->Cell($colWidths[$i], 6, $headers[$i], 1, 0, 'C', true); + $x += $colWidths[$i]; + } + + // Table content + $pdf->SetFont('dejavusans', '', 7); + $y = 34; + $lineNum = 1; + + // Build equipment lookup + $eqLookup = array(); + foreach ($equipmentList as $eq) { + $eqLookup[$eq->id] = $eq; + } + + foreach ($connections as $conn) { + // Skip rails/busbars in wiring list (they're separate) + if ($conn->is_rail) continue; + + $sourceName = '-'; + $sourceTerminal = $conn->source_terminal ?: '-'; + $targetName = '-'; + $targetTerminal = $conn->target_terminal ?: '-'; + + if ($conn->fk_source && isset($eqLookup[$conn->fk_source])) { + $sourceName = $eqLookup[$conn->fk_source]->label ?: $eqLookup[$conn->fk_source]->type_label_short; + } + if ($conn->fk_target && isset($eqLookup[$conn->fk_target])) { + $targetName = $eqLookup[$conn->fk_target]->label ?: $eqLookup[$conn->fk_target]->type_label_short; + } + + // Connection type / medium + $connType = $conn->connection_type ?: '-'; + $medium = trim($conn->medium_type.' '.$conn->medium_spec); + if (empty($medium)) $medium = '-'; + + $remark = $conn->output_label ?: ''; + + if ($y > $pageHeight - 25) { + // New page + $pdf->AddPage($orientation, array($pageWidth, $pageHeight)); + $y = 10; + + // Repeat header + $pdf->SetFont('dejavusans', 'B', 8); + $x = 10; + for ($i = 0; $i < count($headers); $i++) { + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[$i], 6, $headers[$i], 1, 0, 'C', true); + $x += $colWidths[$i]; + } + $y += 6; + $pdf->SetFont('dejavusans', '', 7); + } + + $x = 10; + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[0], 5, $lineNum, 1, 0, 'C'); + $x += $colWidths[0]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[1], 5, dol_trunc($sourceName, 18), 1, 0, 'L'); + $x += $colWidths[1]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[2], 5, $sourceTerminal, 1, 0, 'C'); + $x += $colWidths[2]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[3], 5, dol_trunc($targetName, 18), 1, 0, 'L'); + $x += $colWidths[3]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[4], 5, $targetTerminal, 1, 0, 'C'); + $x += $colWidths[4]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[5], 5, $connType, 1, 0, 'C'); + $x += $colWidths[5]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[6], 5, dol_trunc($medium, 15), 1, 0, 'L'); + $x += $colWidths[6]; + + $pdf->SetXY($x, $y); + $pdf->Cell($colWidths[7], 5, dol_trunc($remark, 15), 1, 0, 'L'); + + $y += 5; + $lineNum++; + } + + // Add busbars section if any + $busbars = array_filter($connections, function($c) { return $c->is_rail; }); + if (count($busbars) > 0) { + $y += 10; + if ($y > $pageHeight - 40) { + $pdf->AddPage($orientation, array($pageWidth, $pageHeight)); + $y = 10; + } + + $pdf->SetFont('dejavusans', 'B', 10); + $pdf->SetXY(10, $y); + $pdf->Cell(0, 6, 'SAMMELSCHIENEN / PHASENSCHIENEN', 0, 1, 'L'); + $y += 8; + + $pdf->SetFont('dejavusans', 'B', 8); + $bbHeaders = array('Nr.', 'Bezeichnung', 'Typ', 'Von TE', 'Bis TE', 'Phasen', 'Ausnahmen'); + $bbWidths = array(15, 50, 30, 20, 20, 30, 50); + + $x = 10; + for ($i = 0; $i < count($bbHeaders); $i++) { + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[$i], 6, $bbHeaders[$i], 1, 0, 'C', true); + $x += $bbWidths[$i]; + } + $y += 6; + + $pdf->SetFont('dejavusans', '', 7); + $bbNum = 1; + foreach ($busbars as $bb) { + $x = 10; + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[0], 5, $bbNum, 1, 0, 'C'); + $x += $bbWidths[0]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[1], 5, $bb->output_label ?: 'Sammelschiene '.$bbNum, 1, 0, 'L'); + $x += $bbWidths[1]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[2], 5, $bb->connection_type ?: '-', 1, 0, 'C'); + $x += $bbWidths[2]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[3], 5, $bb->rail_start_te ?: '-', 1, 0, 'C'); + $x += $bbWidths[3]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[4], 5, $bb->rail_end_te ?: '-', 1, 0, 'C'); + $x += $bbWidths[4]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[5], 5, $bb->rail_phases ?: '-', 1, 0, 'C'); + $x += $bbWidths[5]; + + $pdf->SetXY($x, $y); + $pdf->Cell($bbWidths[6], 5, $bb->excluded_te ?: '-', 1, 0, 'L'); + + $y += 5; + $bbNum++; + } + } +} + +// Output PDF +$filename = 'Leitungslaufplan_'.dol_sanitizeFileName($anlage->label).'_'.date('Y-m-d').'.pdf'; +$pdf->Output($filename, 'D'); + +/** + * Draw schematic content directly in PDF + * Shows only the actual equipment and connections from the database (what was drawn in the editor) + */ +function drawSchematicContent(&$pdf, $carriers, $equipment, $connections, $startX, $startY, $width, $height) { + // Phase colors (DIN VDE compliant) + $phaseColors = array( + 'L1' => array(139, 69, 19), // Brown + 'L2' => array(0, 0, 0), // Black + 'L3' => array(128, 128, 128), // Gray + 'N' => array(0, 102, 204), // Blue + 'PE' => array(0, 128, 0) // Green (simplified from green-yellow) + ); + + // Layout constants + $teWidth = 10; // mm per TE + $equipmentStartY = $startY + 20; + $blockWidth = 8; + $blockHeight = 20; + + // Calculate total width needed + $maxTE = 0; + foreach ($carriers as $carrier) { + $maxTE = max($maxTE, $carrier->total_te ?: 12); + } + $contentWidth = min($width - 40, $maxTE * $teWidth + 40); + $contentStartX = $startX + 20; + + // ======================================== + // Draw equipment and connections + // ======================================== + $carrierIndex = 0; + foreach ($carriers as $carrier) { + $carrierY = $equipmentStartY + $carrierIndex * 50; + $carrierX = $contentStartX; + $totalTE = $carrier->total_te ?: 12; + + // Carrier label + $pdf->SetFont('dejavusans', '', 6); + $pdf->SetTextColor(100, 100, 100); + $pdf->SetXY($carrierX - 15, $carrierY + $blockHeight / 2 - 2); + $pdf->Cell(12, 4, $carrier->label ?: 'H'.($carrierIndex+1), 0, 0, 'R'); + + // Get equipment on this carrier + $carrierEquipment = array_filter($equipment, function($eq) use ($carrier) { + return $eq->fk_carrier == $carrier->id; + }); + + // Get busbars for this carrier + $carrierBusbars = array_filter($connections, function($c) use ($carrier) { + return $c->is_rail && $c->fk_carrier == $carrier->id; + }); + + // Sort equipment by position + usort($carrierEquipment, function($a, $b) { + return ($a->position_te ?: 1) - ($b->position_te ?: 1); + }); + + // Draw each equipment + foreach ($carrierEquipment as $eq) { + $eqPosTE = $eq->position_te ?: 1; + $eqWidthTE = $eq->width_te ?: 1; + $eqX = $carrierX + ($eqPosTE - 1) * $teWidth; + $eqWidth = $eqWidthTE * $teWidth - 2; + + // Equipment block + $color = $eq->type_color ?: '#3498db'; + list($r, $g, $b) = sscanf($color, "#%02x%02x%02x"); + $pdf->SetFillColor($r ?: 52, $g ?: 152, $b ?: 219); + $pdf->Rect($eqX, $carrierY, $eqWidth, $blockHeight, 'F'); + + // Equipment label + $pdf->SetFont('dejavusans', 'B', 5); + $pdf->SetTextColor(255, 255, 255); + $label = $eq->type_label_short ?: $eq->label; + $pdf->SetXY($eqX, $carrierY + 3); + $pdf->Cell($eqWidth, 4, dol_trunc($label, 8), 0, 0, 'C'); + + // Second line label + if ($eq->label && $eq->type_label_short) { + $pdf->SetFont('dejavusans', '', 4); + $pdf->SetXY($eqX, $carrierY + 8); + $pdf->Cell($eqWidth, 3, dol_trunc($eq->label, 10), 0, 0, 'C'); + } + + $pdf->SetTextColor(0, 0, 0); + + // Consumer label below equipment + $pdf->SetFont('dejavusans', '', 5); + $pdf->SetTextColor(80, 80, 80); + $pdf->SetXY($eqX - 2, $carrierY + $blockHeight + 1); + $consumerLabel = $eq->label ?: ''; + $pdf->Cell($eqWidth + 4, 4, dol_trunc($consumerLabel, 12), 0, 0, 'C'); + } + + // Draw busbars (Phasenschienen) for this carrier + foreach ($carrierBusbars as $busbar) { + $busbarStartTE = $busbar->rail_start_te ?: 1; + $busbarEndTE = $busbar->rail_end_te ?: $busbarStartTE; + $busbarX = $carrierX + ($busbarStartTE - 1) * $teWidth; + $busbarWidth = ($busbarEndTE - $busbarStartTE + 1) * $teWidth; + $busbarY = $carrierY - 5; + + // Busbar color + $phase = $busbar->rail_phases ?: $busbar->connection_type ?: 'L1'; + $color = $phaseColors[$phase] ?? array(200, 100, 50); + $pdf->SetFillColor($color[0], $color[1], $color[2]); + $pdf->Rect($busbarX, $busbarY, $busbarWidth, 3, 'F'); + + // Draw vertical taps from busbar to equipment + $pdf->SetDrawColor($color[0], $color[1], $color[2]); + for ($te = $busbarStartTE; $te <= $busbarEndTE; $te++) { + // Check if there's equipment at this TE + $hasEquipment = false; + foreach ($carrierEquipment as $eq) { + $eqStart = $eq->position_te ?: 1; + $eqEnd = $eqStart + ($eq->width_te ?: 1) - 1; + if ($te >= $eqStart && $te <= $eqEnd) { + $hasEquipment = true; + break; + } + } + + if ($hasEquipment) { + $tapX = $carrierX + ($te - 1) * $teWidth + $teWidth / 2; + $pdf->Line($tapX, $busbarY + 3, $tapX, $carrierY); + } + } + + // Busbar label + $pdf->SetFont('dejavusans', 'B', 5); + $pdf->SetTextColor(255, 255, 255); + $pdf->SetXY($busbarX, $busbarY - 0.5); + $pdf->Cell($busbarWidth, 4, $phase, 0, 0, 'C'); + } + + // ======================================== + // Draw Inputs (Anschlusspunkte) and Outputs (Abgänge) + // ======================================== + + // Get non-rail connections for this carrier's equipment + $carrierEqIds = array_map(function($eq) { return $eq->id; }, $carrierEquipment); + + foreach ($connections as $conn) { + if ($conn->is_rail) continue; + + // ANSCHLUSSPUNKT (Input) - fk_source is NULL, fk_target exists + if (empty($conn->fk_source) && !empty($conn->fk_target)) { + // Find target equipment + $targetEq = null; + foreach ($carrierEquipment as $eq) { + if ($eq->id == $conn->fk_target) { + $targetEq = $eq; + break; + } + } + if (!$targetEq) continue; + + $eqPosTE = $targetEq->position_te ?: 1; + $eqWidthTE = $targetEq->width_te ?: 1; + $eqCenterX = $carrierX + ($eqPosTE - 1) * $teWidth + ($eqWidthTE * $teWidth) / 2; + $lineStartY = $carrierY - 18; + $lineEndY = $carrierY; + + // Phase color + $phase = $conn->connection_type ?: 'L1'; + $color = $phaseColors[$phase] ?? array(100, 100, 100); + $pdf->SetDrawColor($color[0], $color[1], $color[2]); + $pdf->SetFillColor($color[0], $color[1], $color[2]); + + // Vertical line from top + $pdf->Line($eqCenterX, $lineStartY, $eqCenterX, $lineEndY); + + // Circle at top (source indicator) + $pdf->Circle($eqCenterX, $lineStartY, 1.5, 0, 360, 'F'); + + // Phase label + $pdf->SetFont('dejavusans', 'B', 7); + $pdf->SetTextColor($color[0], $color[1], $color[2]); + $pdf->SetXY($eqCenterX - 5, $lineStartY - 5); + $pdf->Cell(10, 4, $phase, 0, 0, 'C'); + + // Optional label + if (!empty($conn->output_label)) { + $pdf->SetFont('dejavusans', '', 5); + $pdf->SetTextColor(100, 100, 100); + $pdf->SetXY($eqCenterX + 3, $lineStartY - 4); + $pdf->Cell(20, 3, dol_trunc($conn->output_label, 12), 0, 0, 'L'); + } + } + + // ABGANG (Output) - fk_source exists, fk_target is NULL + if (!empty($conn->fk_source) && empty($conn->fk_target)) { + // Find source equipment + $sourceEq = null; + foreach ($carrierEquipment as $eq) { + if ($eq->id == $conn->fk_source) { + $sourceEq = $eq; + break; + } + } + if (!$sourceEq) continue; + + $eqPosTE = $sourceEq->position_te ?: 1; + $eqWidthTE = $sourceEq->width_te ?: 1; + $eqCenterX = $carrierX + ($eqPosTE - 1) * $teWidth + ($eqWidthTE * $teWidth) / 2; + $lineStartY = $carrierY + $blockHeight; + $lineLength = 18; + $lineEndY = $lineStartY + $lineLength; + + // Phase color + $phase = $conn->connection_type ?: 'L1N'; + $color = $phaseColors[$phase] ?? $phaseColors['L1'] ?? array(139, 69, 19); + $pdf->SetDrawColor($color[0], $color[1], $color[2]); + $pdf->SetFillColor($color[0], $color[1], $color[2]); + + // Vertical line going down + $pdf->Line($eqCenterX, $lineStartY, $eqCenterX, $lineEndY); + + // Arrow at end + $pdf->Polygon(array( + $eqCenterX - 1.5, $lineEndY - 2, + $eqCenterX, $lineEndY, + $eqCenterX + 1.5, $lineEndY - 2 + ), 'F'); + + // Left label: Bezeichnung (rotated text not easy in TCPDF, use horizontal) + if (!empty($conn->output_label)) { + $pdf->SetFont('dejavusans', 'B', 5); + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY($eqCenterX - 15, $lineEndY + 1); + $pdf->Cell(30, 3, dol_trunc($conn->output_label, 15), 0, 0, 'C'); + } + + // Right label: Kabeltyp + Größe + $cableInfo = trim(($conn->medium_type ?: '') . ' ' . ($conn->medium_spec ?: '')); + if (!empty($cableInfo)) { + $pdf->SetFont('dejavusans', '', 4); + $pdf->SetTextColor(100, 100, 100); + $pdf->SetXY($eqCenterX - 15, $lineEndY + 4); + $pdf->Cell(30, 3, dol_trunc($cableInfo, 18), 0, 0, 'C'); + } + + // Phase type + $pdf->SetFont('dejavusans', 'B', 5); + $pdf->SetTextColor($color[0], $color[1], $color[2]); + $pdf->SetXY($eqCenterX - 5, $lineEndY + 7); + $pdf->Cell(10, 3, $phase, 0, 0, 'C'); + } + } + + $carrierIndex++; + } + + $pdf->SetTextColor(0, 0, 0); + + // ======================================== + // Legend - show phase colors used in busbars + // ======================================== + $legendY = $startY + $height - 20; + $pdf->SetFont('dejavusans', '', 6); + $pdf->SetXY($startX + 5, $legendY); + $pdf->Cell(0, 4, 'Phasenfarben nach DIN VDE:', 0, 1, 'L'); + + $legendX = $startX + 5; + $phases = array('L1', 'L2', 'L3', 'N', 'PE'); + foreach ($phases as $idx => $phase) { + $color = $phaseColors[$phase]; + $pdf->SetFillColor($color[0], $color[1], $color[2]); + $pdf->Rect($legendX + $idx * 25, $legendY + 5, 8, 3, 'F'); + $pdf->SetTextColor($color[0], $color[1], $color[2]); + $pdf->SetXY($legendX + $idx * 25 + 10, $legendY + 4); + $pdf->Cell(12, 4, $phase, 0, 0, 'L'); + } + + $pdf->SetTextColor(0, 0, 0); +} diff --git a/ajax/export_tree_pdf.php b/ajax/export_tree_pdf.php new file mode 100755 index 0000000..082b05f --- /dev/null +++ b/ajax/export_tree_pdf.php @@ -0,0 +1,432 @@ +loadLangs(array('companies', 'kundenkarte@kundenkarte')); + +// Get parameters +$socId = GETPOSTINT('socid'); +$contactId = GETPOSTINT('contactid'); +$systemId = GETPOSTINT('system'); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + accessforbidden(); +} + +// Load company +$societe = new Societe($db); +$societe->fetch($socId); + +// Load contact if specified +$contact = null; +if ($contactId > 0) { + $contact = new Contact($db); + $contact->fetch($contactId); +} + +// Load system info +$systemLabel = ''; +$sql = "SELECT label FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE rowid = ".((int) $systemId); +$resql = $db->query($sql); +if ($resql && $obj = $db->fetch_object($resql)) { + $systemLabel = $obj->label; +} + +// Load tree +$anlage = new Anlage($db); +if ($contactId > 0) { + $tree = $anlage->fetchTreeByContact($socId, $contactId, $systemId); +} else { + $tree = $anlage->fetchTree($socId, $systemId); +} + +// Load all type fields for display (including headers) +$typeFieldsMap = array(); +$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + if (!isset($typeFieldsMap[$obj->fk_anlage_type])) { + $typeFieldsMap[$obj->fk_anlage_type] = array(); + } + $typeFieldsMap[$obj->fk_anlage_type][] = $obj; + } +} + +// Create PDF +$pdf = pdf_getInstance(); +$pdf->SetCreator('Dolibarr - Kundenkarte'); +$pdf->SetAuthor($user->getFullName($langs)); + +$title = $systemLabel.' - '.$societe->name; +if ($contact) { + $title .= ' - '.$contact->getFullName($langs); +} +$pdf->SetTitle($title); + +$pdf->SetMargins(15, 15, 15); +$pdf->SetAutoPageBreak(true, 15); +$pdf->SetFont('dejavusans', '', 9); + +// Check for PDF template +$tplidx = null; +$templateFile = $conf->kundenkarte->dir_output.'/templates/export_template.pdf'; +if (file_exists($templateFile) && is_readable($templateFile)) { + try { + $pagecount = $pdf->setSourceFile($templateFile); + $tplidx = $pdf->importPage(1); + } catch (Exception $e) { + // Template could not be loaded, continue without + $tplidx = null; + } +} + +$pdf->AddPage(); +if (!empty($tplidx)) { + $pdf->useTemplate($tplidx); +} + +// Compact header - left aligned +$pdf->SetFont('dejavusans', 'B', 14); +$pdf->Cell(120, 6, $systemLabel, 0, 1, 'L'); + +$pdf->SetFont('dejavusans', '', 9); +$pdf->SetTextColor(80, 80, 80); + +// Customer info in one compact block +$customerInfo = $societe->name; +if ($contact) { + $customerInfo .= ' | '.$contact->getFullName($langs); +} +$pdf->Cell(120, 4, $customerInfo, 0, 1, 'L'); + +// Address +$address = array(); +if ($societe->address) $address[] = $societe->address; +if ($societe->zip || $societe->town) $address[] = trim($societe->zip.' '.$societe->town); +if (!empty($address)) { + $pdf->Cell(120, 4, implode(', ', $address), 0, 1, 'L'); +} + +// Date and count +$totalElements = countTreeElements($tree); +$pdf->Cell(120, 4, dol_print_date(dol_now(), 'day').' | '.$totalElements.' Elemente', 0, 1, 'L'); + +$pdf->SetTextColor(0, 0, 0); +$pdf->Ln(2); + +// Separator line +$pdf->SetDrawColor(200, 200, 200); +$pdf->Line(15, $pdf->GetY(), $pdf->getPageWidth() - 15, $pdf->GetY()); +$pdf->Ln(4); + +// Get font size settings +$fontSettings = array( + 'header' => getDolGlobalInt('KUNDENKARTE_PDF_FONT_HEADER', 9), + 'content' => getDolGlobalInt('KUNDENKARTE_PDF_FONT_CONTENT', 7), + 'fields' => getDolGlobalInt('KUNDENKARTE_PDF_FONT_FIELDS', 7) +); + +// Draw tree +if (!empty($tree)) { + drawTreePdf($pdf, $tree, $typeFieldsMap, $langs, 0, $tplidx, $fontSettings); +} else { + $pdf->SetFont('dejavusans', 'I', 10); + $pdf->Cell(0, 10, $langs->trans('NoInstallations'), 0, 1); +} + +/** + * Count total elements in tree + */ +function countTreeElements($nodes) { + $count = 0; + if (!empty($nodes)) { + foreach ($nodes as $node) { + $count++; + if (!empty($node->children)) { + $count += countTreeElements($node->children); + } + } + } + return $count; +} + +// Output PDF +$filename = 'Export_'.$systemLabel.'_'.dol_sanitizeFileName($societe->name); +if ($contact) { + $filename .= '_'.dol_sanitizeFileName($contact->lastname); +} +$filename .= '_'.date('Y-m-d').'.pdf'; + +$pdf->Output($filename, 'D'); + +/** + * Draw tree recursively in PDF with visual hierarchy + */ +function drawTreePdf(&$pdf, $nodes, $typeFieldsMap, $langs, $level = 0, $tplidx = null, $fontSettings = null) +{ + // Default font settings if not provided + if ($fontSettings === null) { + $fontSettings = array('header' => 9, 'content' => 7, 'fields' => 7); + } + + $indent = $level * 12; + $leftMargin = 15; + $pageWidth = $pdf->getPageWidth() - 30; + $contentWidth = $pageWidth - $indent; + + // Subtle gray tones - darker for higher levels, lighter for deeper levels + $levelColors = array( + 0 => array('bg' => array(70, 70, 70), 'border' => array(50, 50, 50)), // Dark gray + 1 => array('bg' => array(100, 100, 100), 'border' => array(80, 80, 80)), // Medium dark + 2 => array('bg' => array(130, 130, 130), 'border' => array(110, 110, 110)), // Medium + 3 => array('bg' => array(150, 150, 150), 'border' => array(130, 130, 130)), // Medium light + 4 => array('bg' => array(170, 170, 170), 'border' => array(150, 150, 150)), // Light gray + ); + $colorIndex = min($level, count($levelColors) - 1); + $colors = $levelColors[$colorIndex]; + + $nodeCount = count($nodes); + $nodeIndex = 0; + + foreach ($nodes as $node) { + $nodeIndex++; + $isLast = ($nodeIndex == $nodeCount); + + // Calculate content height to check page break + $estimatedHeight = 12; // Header height + $fieldValues = $node->getFieldValues(); + if (!empty($typeFieldsMap[$node->fk_anlage_type])) { + foreach ($typeFieldsMap[$node->fk_anlage_type] as $fieldDef) { + if ($fieldDef->field_type === 'header') { + $estimatedHeight += 5; + } else { + $value = isset($fieldValues[$fieldDef->field_code]) ? $fieldValues[$fieldDef->field_code] : ''; + if ($value !== '') $estimatedHeight += 4; + } + } + } + if ($node->note_private) $estimatedHeight += 8; + if ($node->image_count > 0 || $node->doc_count > 0) $estimatedHeight += 4; + + // Check if we need a new page + if ($pdf->GetY() + $estimatedHeight > 265) { + $pdf->AddPage(); + if (!empty($tplidx)) { + $pdf->useTemplate($tplidx); + } + } + + $startY = $pdf->GetY(); + $boxX = $leftMargin + $indent; + + // Draw tree connector lines + if ($level > 0) { + $pdf->SetDrawColor(180, 180, 180); + $pdf->SetLineWidth(0.3); + + // Horizontal line to element + $lineStartX = $boxX - 8; + $lineEndX = $boxX - 2; + $lineY = $startY + 4; + $pdf->Line($lineStartX, $lineY, $lineEndX, $lineY); + + // Vertical line segment + $pdf->Line($lineStartX, $startY - 2, $lineStartX, $lineY); + } + + // Draw element box with rounded corners + $pdf->SetDrawColor($colors['border'][0], $colors['border'][1], $colors['border'][2]); + $pdf->SetLineWidth(0.4); + + // Header bar with color + $pdf->SetFillColor($colors['bg'][0], $colors['bg'][1], $colors['bg'][2]); + $pdf->RoundedRect($boxX, $startY, $contentWidth, 8, 1.5, '1100', 'DF'); + + // Content area with light background + $pdf->SetFillColor(250, 250, 250); + $pdf->SetDrawColor(220, 220, 220); + + // Element header text (white on colored background) + $pdf->SetXY($boxX + 3, $startY + 1.5); + $pdf->SetFont('dejavusans', 'B', $fontSettings['header']); + $pdf->SetTextColor(255, 255, 255); + + $headerText = $node->label; + if ($node->type_label) { + $headerText .= ' · '.$node->type_label; + } + $pdf->Cell($contentWidth - 6, 5, $headerText, 0, 1, 'L'); + + $pdf->SetTextColor(0, 0, 0); + $contentStartY = $startY + 8; + $pdf->SetY($contentStartY); + + // Collect content to measure height + $hasContent = false; + $contentY = $contentStartY + 2; + + // Draw fields + if (!empty($typeFieldsMap[$node->fk_anlage_type])) { + foreach ($typeFieldsMap[$node->fk_anlage_type] as $fieldDef) { + // Handle header fields as section titles + if ($fieldDef->field_type === 'header') { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', 'B', $fontSettings['fields']); + $pdf->SetTextColor(100, 100, 100); + $pdf->Cell($contentWidth - 8, 4, strtoupper($fieldDef->field_label), 0, 1); + $pdf->SetTextColor(0, 0, 0); + $contentY += 4; + $hasContent = true; + continue; + } + + $value = isset($fieldValues[$fieldDef->field_code]) ? $fieldValues[$fieldDef->field_code] : ''; + + if ($value !== '') { + // Format date values + if ($fieldDef->field_type === 'date' && $value) { + $value = dol_print_date(strtotime($value), 'day'); + } + + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', '', $fontSettings['fields']); + $pdf->SetTextColor(120, 120, 120); + $pdf->Cell(30, 3.5, $fieldDef->field_label.':', 0, 0); + $pdf->SetFont('dejavusans', '', $fontSettings['content']); + $pdf->SetTextColor(50, 50, 50); + $pdf->Cell($contentWidth - 38, 3.5, $value, 0, 1); + $contentY += 3.5; + $hasContent = true; + } + } + } + + // Notes + if ($node->note_private) { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', 'I', $fontSettings['content']); + $pdf->SetTextColor(100, 100, 100); + $noteText = strip_tags($node->note_private); + if (strlen($noteText) > 80) { + $noteText = substr($noteText, 0, 77).'...'; + } + $pdf->Cell($contentWidth - 8, 3.5, $noteText, 0, 1); + $contentY += 3.5; + $hasContent = true; + } + + // File counts + if ($node->image_count > 0 || $node->doc_count > 0) { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', '', $fontSettings['content']); + $pdf->SetTextColor(150, 150, 150); + $fileInfo = array(); + if ($node->image_count > 0) { + $fileInfo[] = $node->image_count.' '.($node->image_count == 1 ? 'Bild' : 'Bilder'); + } + if ($node->doc_count > 0) { + $fileInfo[] = $node->doc_count.' '.($node->doc_count == 1 ? 'Dok.' : 'Dok.'); + } + $pdf->Cell($contentWidth - 8, 3.5, implode(' | ', $fileInfo), 0, 1); + $contentY += 3.5; + $hasContent = true; + } + + // Draw content box if there's content + if ($hasContent) { + $contentHeight = $contentY - $contentStartY + 2; + $pdf->SetDrawColor(220, 220, 220); + $pdf->SetFillColor(252, 252, 252); + $pdf->RoundedRect($boxX, $contentStartY, $contentWidth, $contentHeight, 1.5, '0011', 'DF'); + + // Redraw content on top of box + $contentY = $contentStartY + 2; + + if (!empty($typeFieldsMap[$node->fk_anlage_type])) { + foreach ($typeFieldsMap[$node->fk_anlage_type] as $fieldDef) { + if ($fieldDef->field_type === 'header') { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', 'B', $fontSettings['fields']); + $pdf->SetTextColor(100, 100, 100); + $pdf->Cell($contentWidth - 8, 4, strtoupper($fieldDef->field_label), 0, 1); + $pdf->SetTextColor(0, 0, 0); + $contentY += 4; + continue; + } + + $value = isset($fieldValues[$fieldDef->field_code]) ? $fieldValues[$fieldDef->field_code] : ''; + + if ($value !== '') { + if ($fieldDef->field_type === 'date' && $value) { + $value = dol_print_date(strtotime($value), 'day'); + } + + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', '', $fontSettings['fields']); + $pdf->SetTextColor(120, 120, 120); + $pdf->Cell(30, 3.5, $fieldDef->field_label.':', 0, 0); + $pdf->SetFont('dejavusans', '', $fontSettings['content']); + $pdf->SetTextColor(50, 50, 50); + $pdf->Cell($contentWidth - 38, 3.5, $value, 0, 1); + $contentY += 3.5; + } + } + } + + if ($node->note_private) { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', 'I', $fontSettings['content']); + $pdf->SetTextColor(100, 100, 100); + $noteText = strip_tags($node->note_private); + if (strlen($noteText) > 80) { + $noteText = substr($noteText, 0, 77).'...'; + } + $pdf->Cell($contentWidth - 8, 3.5, $noteText, 0, 1); + $contentY += 3.5; + } + + if ($node->image_count > 0 || $node->doc_count > 0) { + $pdf->SetXY($boxX + 4, $contentY); + $pdf->SetFont('dejavusans', '', $fontSettings['content']); + $pdf->SetTextColor(150, 150, 150); + $fileInfo = array(); + if ($node->image_count > 0) { + $fileInfo[] = $node->image_count.' '.($node->image_count == 1 ? 'Bild' : 'Bilder'); + } + if ($node->doc_count > 0) { + $fileInfo[] = $node->doc_count.' '.($node->doc_count == 1 ? 'Dok.' : 'Dok.'); + } + $pdf->Cell($contentWidth - 8, 3.5, implode(' | ', $fileInfo), 0, 1); + $contentY += 3.5; + } + + $pdf->SetY($contentStartY + $contentHeight + 3); + } else { + $pdf->SetY($startY + 11); + } + + $pdf->SetTextColor(0, 0, 0); + + // Children + if (!empty($node->children)) { + drawTreePdf($pdf, $node->children, $typeFieldsMap, $langs, $level + 1, $tplidx, $fontSettings); + } + } +} diff --git a/ajax/favorite_update.php b/ajax/favorite_update.php new file mode 100755 index 0000000..4c12083 --- /dev/null +++ b/ajax/favorite_update.php @@ -0,0 +1,78 @@ +hasRight('kundenkarte', 'write')) { + http_response_code(403); + echo json_encode(array('error' => 'Permission denied')); + exit; +} + +header('Content-Type: application/json'); + +$id = GETPOSTINT('id'); +$qty = GETPOST('qty', 'alpha'); + +if ($id <= 0) { + echo json_encode(array('error' => 'Invalid ID')); + exit; +} + +// Simple check: just needs to be numeric +if (!is_numeric($qty)) { + echo json_encode(array('error' => 'Invalid quantity')); + exit; +} + +$qty = (float) $qty; + +$favoriteProduct = new FavoriteProduct($db); +$result = $favoriteProduct->fetch($id); + +if ($result <= 0) { + echo json_encode(array('error' => 'Record not found')); + exit; +} + +$favoriteProduct->qty = $qty; +$result = $favoriteProduct->update($user); + +if ($result > 0) { + echo json_encode(array( + 'success' => true, + 'id' => $id, + 'qty' => $qty + )); +} else { + echo json_encode(array('error' => $favoriteProduct->error)); +} diff --git a/ajax/field_autocomplete.php b/ajax/field_autocomplete.php new file mode 100755 index 0000000..67d6350 --- /dev/null +++ b/ajax/field_autocomplete.php @@ -0,0 +1,106 @@ +hasRight('kundenkarte', 'read')) { + echo json_encode(array('error' => 'Access denied')); + exit; +} + +$action = GETPOST('action', 'aZ09'); +$fieldCode = GETPOST('field_code', 'aZ09'); +$query = GETPOST('query', 'alphanohtml'); +$typeId = GETPOSTINT('type_id'); + +if ($action == 'suggest') { + $suggestions = array(); + + if (empty($fieldCode)) { + echo json_encode(array('suggestions' => $suggestions)); + exit; + } + + // Get unique saved values for this field code + // Search in JSON field_values column + $sql = "SELECT DISTINCT "; + $sql .= "JSON_UNQUOTE(JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"')) as field_value "; + $sql .= "FROM ".MAIN_DB_PREFIX."kundenkarte_anlage "; + $sql .= "WHERE field_values IS NOT NULL "; + $sql .= "AND JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"') IS NOT NULL "; + + // Filter by query if provided + if (!empty($query)) { + $sql .= "AND JSON_UNQUOTE(JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"')) LIKE '%".$db->escape($query)."%' "; + } + + // Optionally filter by type + if ($typeId > 0) { + $sql .= "AND fk_anlage_type = ".((int) $typeId)." "; + } + + $sql .= "ORDER BY field_value ASC "; + $sql .= "LIMIT 20"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + if (!empty($obj->field_value) && $obj->field_value !== 'null') { + $suggestions[] = $obj->field_value; + } + } + $db->free($resql); + } + + echo json_encode(array('suggestions' => $suggestions)); + exit; +} + +// Get all autocomplete-enabled fields for a type +if ($action == 'get_autocomplete_fields') { + $fields = array(); + + if ($typeId > 0) { + $sql = "SELECT field_code, field_label FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field "; + $sql .= "WHERE fk_anlage_type = ".((int) $typeId)." "; + $sql .= "AND enable_autocomplete = 1 "; + $sql .= "AND active = 1 "; + $sql .= "AND field_type IN ('text', 'textarea')"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $fields[] = array( + 'code' => $obj->field_code, + 'label' => $obj->field_label + ); + } + $db->free($resql); + } + } + + echo json_encode(array('fields' => $fields)); + exit; +} + +echo json_encode(array('error' => 'Unknown action')); diff --git a/ajax/file_preview.php b/ajax/file_preview.php new file mode 100755 index 0000000..f338859 --- /dev/null +++ b/ajax/file_preview.php @@ -0,0 +1,101 @@ +hasRight('kundenkarte', 'read')) { + http_response_code(403); + echo json_encode(['error' => 'Permission denied']); + exit; +} + +$anlageId = GETPOSTINT('anlage_id'); + +if ($anlageId <= 0) { + echo json_encode(array('error' => 'Invalid ID', 'files' => array())); + exit; +} + +// Get the anlage +$anlage = new Anlage($db); +if ($anlage->fetch($anlageId) <= 0) { + echo json_encode(array('error' => 'Element not found', 'files' => array())); + exit; +} + +// Get all files for this element +$anlagefile = new AnlageFile($db); +$files = $anlagefile->fetchAllByAnlage($anlageId); + +$images = array(); +$documents = array(); + +foreach ($files as $file) { + $fileData = array( + 'id' => $file->id, + 'name' => $file->filename, + 'url' => $file->getUrl(), + 'type' => $file->file_type, + 'is_pinned' => (int)$file->is_pinned, + 'is_cover' => (int)$file->is_cover, + ); + + if ($file->file_type == 'image') { + // Add thumbnail URL for images + $fileData['thumb_url'] = $file->getThumbnailUrl(); + $images[] = $fileData; + } else { + // Determine icon based on file type + switch ($file->file_type) { + case 'pdf': + $fileData['icon'] = 'fa-file-pdf-o'; + $fileData['color'] = '#e74c3c'; + break; + case 'archive': + $fileData['icon'] = 'fa-file-archive-o'; + $fileData['color'] = '#9b59b6'; + break; + default: + // Check extension for more specific icons + $ext = strtolower(pathinfo($file->filename, PATHINFO_EXTENSION)); + if (in_array($ext, array('doc', 'docx'))) { + $fileData['icon'] = 'fa-file-word-o'; + $fileData['color'] = '#2980b9'; + } elseif (in_array($ext, array('xls', 'xlsx'))) { + $fileData['icon'] = 'fa-file-excel-o'; + $fileData['color'] = '#27ae60'; + } elseif (in_array($ext, array('txt', 'rtf'))) { + $fileData['icon'] = 'fa-file-text-o'; + $fileData['color'] = '#f39c12'; + } else { + $fileData['icon'] = 'fa-file-o'; + $fileData['color'] = '#7f8c8d'; + } + } + $documents[] = $fileData; + } +} + +echo json_encode(array( + 'images' => $images, + 'documents' => $documents, + 'total_images' => count($images), + 'total_documents' => count($documents), +)); diff --git a/ajax/graph_data.php b/ajax/graph_data.php new file mode 100755 index 0000000..0646738 --- /dev/null +++ b/ajax/graph_data.php @@ -0,0 +1,307 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$socId = GETPOSTINT('socid'); +$contactId = GETPOSTINT('contactid'); + +$response = array('success' => false, 'error' => ''); + +// Berechtigungsprüfung +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +if ($socId <= 0) { + $response['error'] = 'Missing socid'; + echo json_encode($response); + exit; +} + +// Feld-Metadaten laden (field_code → Label, Display-Modus, Badge-Farbe pro Typ) +// Sortiert nach position → Reihenfolge wird in der Graph-Ansicht beibehalten +$fieldMeta = array(); // [fk_anlage_type][field_code] = {label, display_mode, badge_color, show_in_tree, show_in_hover} +$sqlFields = "SELECT fk_anlage_type, field_code, field_label, field_type, tree_display_mode, badge_color, show_in_tree, show_in_hover"; +$sqlFields .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field WHERE active = 1 ORDER BY position"; +$resFields = $db->query($sqlFields); +if ($resFields) { + while ($fObj = $db->fetch_object($resFields)) { + if ($fObj->field_type === 'header') continue; + $fieldMeta[(int)$fObj->fk_anlage_type][$fObj->field_code] = array( + 'label' => $fObj->field_label, + 'display' => $fObj->tree_display_mode ?: 'badge', + 'color' => $fObj->badge_color ?: '', + 'type' => $fObj->field_type, + 'show_in_tree' => (int) $fObj->show_in_tree, + 'show_in_hover' => (int) $fObj->show_in_hover, + ); + } + $db->free($resFields); +} + +// Elemente laden - OHNE GLOBAL-System (das ist nur die separate Gebäudestruktur) +// Gebäude/Räume werden über den Typ erkannt (type_system_code = GLOBAL) +// Hierarchie kommt aus fk_parent (wie im Baum) +$sql = "SELECT a.rowid, a.label, a.fk_parent, a.fk_system, a.fk_anlage_type,"; +$sql .= " a.field_values, a.fk_contact, a.graph_x, a.graph_y,"; +$sql .= " t.label as type_label, t.picto as type_picto, t.color as type_color,"; +$sql .= " s.code as system_code, s.label as system_label,"; +$sql .= " ts.code as type_system_code,"; +$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type IN ('image/jpeg','image/png','image/gif','image/webp')) as image_count,"; +$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type NOT IN ('image/jpeg','image/png','image/gif','image/webp')) as doc_count"; +$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage as a"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage_type as t ON a.fk_anlage_type = t.rowid"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON a.fk_system = s.rowid"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as ts ON t.fk_system = ts.rowid"; +$sql .= " WHERE a.fk_soc = ".(int)$socId; +$sql .= " AND s.code != 'GLOBAL'"; +if ($contactId > 0) { + $sql .= " AND a.fk_contact = ".(int)$contactId; +} else { + // Auf Kunden-Ebene nur Elemente ohne Kontaktzuweisung (wie im Baum) + $sql .= " AND (a.fk_contact IS NULL OR a.fk_contact = 0)"; +} +$sql .= " AND a.status = 1"; +$sql .= " ORDER BY a.fk_parent, a.rang, a.rowid"; + +$elements = array('nodes' => array(), 'edges' => array()); +$nodeIds = array(); +// Zwischenspeicher: rowid → isBuilding (für Compound-Entscheidung) +$nodeIsBuilding = array(); +// Hierarchie-Kanten (Gerät→Gerät), werden durch echte Kabel ersetzt falls vorhanden +$hierarchyEdges = array(); +// Zwischenspeicher: alle DB-Zeilen für Zwei-Pass-Verarbeitung +$rows = array(); + +$resql = $db->query($sql); +if ($resql) { + // 1. Pass: Alle Zeilen laden und Gebäude-Typen merken + while ($obj = $db->fetch_object($resql)) { + $isBuilding = (!empty($obj->type_system_code) && $obj->type_system_code === 'GLOBAL'); + $nodeIsBuilding[(int)$obj->rowid] = $isBuilding; + $nodeIds[$obj->rowid] = true; + $rows[] = $obj; + } + $db->free($resql); + + // 2. Pass: Nodes und Hierarchie-Edges aufbauen + foreach ($rows as $obj) { + $isBuilding = $nodeIsBuilding[(int)$obj->rowid]; + + $nodeId = 'n_'.$obj->rowid; + + $nodeData = array( + 'id' => $nodeId, + 'label' => $obj->label, + 'type_label' => $obj->type_label ?: '', + 'type_picto' => $obj->type_picto ?: '', + 'type_color' => $obj->type_color ?: '', + 'system_code' => $obj->system_code ?: '', + 'system_label' => $obj->system_label ?: '', + 'fk_parent' => (int) $obj->fk_parent, + 'fk_anlage_type' => (int) $obj->fk_anlage_type, + 'is_building' => $isBuilding, + 'image_count' => (int) $obj->image_count, + 'doc_count' => (int) $obj->doc_count, + 'graph_x' => $obj->graph_x !== null ? (float) $obj->graph_x : null, + 'graph_y' => $obj->graph_y !== null ? (float) $obj->graph_y : null, + ); + + // Feldwerte mit Metadaten (Label, Display-Modus, Badge-Farbe) + // Iteration über $fieldMeta (nach position sortiert), nicht über $rawValues (JSON-Reihenfolge) + // Aufteilen: fields = auf dem Node (show_in_tree=1), hover_fields = im Tooltip (show_in_hover=1) + if (!empty($obj->field_values)) { + $rawValues = json_decode($obj->field_values, true); + if (is_array($rawValues) && !empty($rawValues)) { + $typeId = (int) $obj->fk_anlage_type; + $meta = isset($fieldMeta[$typeId]) ? $fieldMeta[$typeId] : array(); + $treeFields = array(); + $hoverFields = array(); + foreach ($meta as $code => $fm) { + $val = isset($rawValues[$code]) ? $rawValues[$code] : null; + if ($val === '' || $val === null) continue; + // Checkbox-Werte anpassen + if ($fm['type'] === 'checkbox') { + $val = $val ? '1' : '0'; + } + $fieldEntry = array( + 'label' => $fm['label'], + 'value' => $val, + 'display' => $fm['display'], + 'color' => $fm['color'], + 'type' => $fm['type'], + ); + // Auf dem Node: nur Felder mit show_in_tree=1 + if (!empty($fm['show_in_tree'])) { + $treeFields[] = $fieldEntry; + } + // Im Tooltip: nur Felder mit show_in_hover=1 + if (!empty($fm['show_in_hover'])) { + $hoverFields[] = $fieldEntry; + } + } + $nodeData['fields'] = $treeFields; + $nodeData['hover_fields'] = $hoverFields; + } + } + + // Compound-Parent: NUR wenn Eltern-Node ein Gebäude/Raum ist + // Gerät→Gerät Hierarchie wird als Kante dargestellt (nicht verschachtelt) + $parentId = (int) $obj->fk_parent; + if ($parentId > 0 && isset($nodeIds[$parentId])) { + $parentIsBuilding = !empty($nodeIsBuilding[$parentId]); + if ($parentIsBuilding) { + // Gebäude/Raum als Container → Compound-Parent + $nodeData['parent'] = 'n_'.$parentId; + } else { + // Gerät→Gerät → Hierarchie-Kante vormerken (wird ggf. durch Kabel ersetzt) + $hierKey = min($parentId, (int)$obj->rowid).'_'.max($parentId, (int)$obj->rowid); + $hierarchyEdges[$hierKey] = array( + 'data' => array( + 'id' => 'hier_'.$parentId.'_'.$obj->rowid, + 'source' => 'n_'.$parentId, + 'target' => 'n_'.$obj->rowid, + 'is_hierarchy' => true, + ), + 'classes' => 'hierarchy-edge' + ); + } + } + + $elements['nodes'][] = array( + 'data' => $nodeData, + 'classes' => $isBuilding ? 'building-node' : 'device-node' + ); + } +} + +// Verbindungen laden +$connObj = new AnlageConnection($db); +$connections = $connObj->fetchBySociete($socId, 0); + +// Verwendete Kabeltypen für die Legende sammeln +$usedCableTypes = array(); + +// Kabel-Paare merken (um Hierarchie-Kanten zu ersetzen) +$cableConnectedPairs = array(); + +foreach ($connections as $conn) { + // Nur Edges für tatsächlich geladene Nodes + if (!isset($nodeIds[$conn->fk_source]) || !isset($nodeIds[$conn->fk_target])) { + continue; + } + + $isPassthrough = empty($conn->label) && empty($conn->medium_type_label) && empty($conn->medium_type_text) && empty($conn->medium_spec); + + // Edge-Label zusammenbauen + $edgeLabel = ''; + $mediumType = !empty($conn->medium_type_label) ? $conn->medium_type_label : $conn->medium_type_text; + if (!empty($mediumType)) { + $edgeLabel = $mediumType; + if (!empty($conn->medium_spec)) { + $edgeLabel .= ' '.$conn->medium_spec; + } + if (!empty($conn->medium_length)) { + $edgeLabel .= ', '.$conn->medium_length; + } + } + + // Farbe: aus Connection oder aus Medium-Type + $color = $conn->medium_color; + if (empty($color) && !empty($conn->fk_medium_type)) { + // Farbe aus Medium-Type-Tabelle holen + $sqlColor = "SELECT color, label_short FROM ".MAIN_DB_PREFIX."kundenkarte_medium_type WHERE rowid = ".(int)$conn->fk_medium_type; + $resColor = $db->query($sqlColor); + if ($resColor && $mtObj = $db->fetch_object($resColor)) { + $color = $mtObj->color; + } + } + + // Kabeltyp für Legende merken + if (!$isPassthrough && !empty($mediumType)) { + $typeKey = $mediumType; + if (!isset($usedCableTypes[$typeKey])) { + $usedCableTypes[$typeKey] = array( + 'label' => $mediumType, + 'color' => $color ?: '#5a8a5a', + ); + } + } + + // Echte Kabelverbindung (nicht durchgeschleift) → Hierarchie-Kante überflüssig + if (!$isPassthrough) { + $src = (int) $conn->fk_source; + $tgt = (int) $conn->fk_target; + $pairKey = min($src, $tgt).'_'.max($src, $tgt); + $cableConnectedPairs[$pairKey] = true; + } + + $elements['edges'][] = array( + 'data' => array( + 'id' => 'conn_'.$conn->id, + 'source' => 'n_'.$conn->fk_source, + 'target' => 'n_'.$conn->fk_target, + 'label' => $edgeLabel, + 'medium_type' => $mediumType, + 'medium_spec' => $conn->medium_spec, + 'medium_length' => $conn->medium_length, + 'medium_color' => $color, + 'connection_id' => (int) $conn->id, + 'is_passthrough' => $isPassthrough, + ), + 'classes' => $isPassthrough ? 'passthrough-edge' : 'cable-edge' + ); +} + +// Hierarchie-Kanten hinzufügen, aber nur wenn kein echtes Kabel zwischen den Geräten existiert +foreach ($hierarchyEdges as $hierKey => $hierEdge) { + if (!isset($cableConnectedPairs[$hierKey])) { + $elements['edges'][] = $hierEdge; + } +} + +// Prüfen ob gespeicherte Positionen vorhanden sind +$hasPositions = false; +foreach ($elements['nodes'] as $node) { + if ($node['data']['graph_x'] !== null) { + $hasPositions = true; + break; + } +} + +$response['success'] = true; +$response['elements'] = $elements; +$response['cable_types'] = array_values($usedCableTypes); +$response['has_positions'] = $hasPositions; +$response['meta'] = array( + 'socid' => $socId, + 'contactid' => $contactId, + 'total_nodes' => count($elements['nodes']), + 'total_connections' => count($connections), +); + +echo json_encode($response); diff --git a/ajax/graph_save_positions.php b/ajax/graph_save_positions.php new file mode 100755 index 0000000..9142cef --- /dev/null +++ b/ajax/graph_save_positions.php @@ -0,0 +1,93 @@ + false, 'error' => ''); + +// Berechtigungsprüfung +if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Keine Berechtigung'; + echo json_encode($response); + exit; +} + +$action = GETPOST('action', 'aZ'); + +if ($action === 'save') { + // Positionen als JSON-Array: [{id: 123, x: 45.6, y: 78.9}, ...] + $rawInput = file_get_contents('php://input'); + $input = json_decode($rawInput, true); + + if (!is_array($input) || empty($input['positions'])) { + $response['error'] = 'Keine Positionen übergeben'; + echo json_encode($response); + exit; + } + + $saved = 0; + $db->begin(); + + foreach ($input['positions'] as $pos) { + $anlageId = (int) ($pos['id'] ?? 0); + $x = (float) ($pos['x'] ?? 0); + $y = (float) ($pos['y'] ?? 0); + if ($anlageId <= 0) continue; + + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage"; + $sql .= " SET graph_x = ".$x.", graph_y = ".$y; + $sql .= " WHERE rowid = ".$anlageId; + if ($db->query($sql)) { + $saved++; + } + } + + $db->commit(); + $response['success'] = true; + $response['saved'] = $saved; + +} elseif ($action === 'reset') { + // Alle Positionen für einen Kunden zurücksetzen + $socId = GETPOSTINT('socid'); + $contactId = GETPOSTINT('contactid'); + + if ($socId <= 0) { + $response['error'] = 'Fehlende socid'; + echo json_encode($response); + exit; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage"; + $sql .= " SET graph_x = NULL, graph_y = NULL"; + $sql .= " WHERE fk_soc = ".(int)$socId; + if ($contactId > 0) { + $sql .= " AND fk_contact = ".(int)$contactId; + } + + if ($db->query($sql)) { + $response['success'] = true; + $response['reset'] = $db->affected_rows; + } else { + $response['error'] = 'Datenbankfehler'; + } + +} else { + $response['error'] = 'Unbekannte Aktion'; +} + +echo json_encode($response); diff --git a/ajax/icon_upload.php b/ajax/icon_upload.php new file mode 100755 index 0000000..2fc0ca2 --- /dev/null +++ b/ajax/icon_upload.php @@ -0,0 +1,145 @@ + 'Include of main fails'))); +} + +header('Content-Type: application/json'); + +// Security check +if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) { + http_response_code(403); + die(json_encode(array('error' => 'Access denied'))); +} + +$action = GETPOST('action', 'aZ09'); + +// Directory for custom icons +$iconDir = DOL_DATA_ROOT.'/kundenkarte/icons'; +$iconUrl = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=icons/'; + +// Create directory if not exists +if (!is_dir($iconDir)) { + dol_mkdir($iconDir); +} + +/** + * List all custom icons + */ +if ($action == 'list') { + $icons = array(); + + if (is_dir($iconDir)) { + $files = scandir($iconDir); + foreach ($files as $file) { + if ($file == '.' || $file == '..') continue; + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + if (in_array($ext, array('png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'))) { + $icons[] = array( + 'filename' => $file, + 'url' => $iconUrl.urlencode($file), + 'name' => pathinfo($file, PATHINFO_FILENAME) + ); + } + } + } + + echo json_encode(array('success' => true, 'icons' => $icons)); + exit; +} + +/** + * Upload a new icon + */ +if ($action == 'upload') { + if (empty($_FILES['icon']) || $_FILES['icon']['error'] != 0) { + http_response_code(400); + die(json_encode(array('error' => 'No file uploaded or upload error'))); + } + + $file = $_FILES['icon']; + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + // Check extension + $allowedExt = array('png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'); + if (!in_array($ext, $allowedExt)) { + http_response_code(400); + die(json_encode(array('error' => 'Invalid file type. Allowed: '.implode(', ', $allowedExt)))); + } + + // Check size (max 500KB) + if ($file['size'] > 512000) { + http_response_code(400); + die(json_encode(array('error' => 'File too large. Max 500KB'))); + } + + // Sanitize filename + $filename = dol_sanitizeFileName($file['name']); + $filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename); + + // Check if file exists, add number if so + $baseName = pathinfo($filename, PATHINFO_FILENAME); + $counter = 1; + while (file_exists($iconDir.'/'.$filename)) { + $filename = $baseName.'_'.$counter.'.'.$ext; + $counter++; + } + + // Move file + if (move_uploaded_file($file['tmp_name'], $iconDir.'/'.$filename)) { + echo json_encode(array( + 'success' => true, + 'icon' => array( + 'filename' => $filename, + 'url' => $iconUrl.urlencode($filename), + 'name' => pathinfo($filename, PATHINFO_FILENAME) + ) + )); + } else { + http_response_code(500); + die(json_encode(array('error' => 'Failed to save file'))); + } + exit; +} + +/** + * Delete an icon + */ +if ($action == 'delete') { + $filename = GETPOST('filename', 'alphanohtml'); + + if (empty($filename)) { + http_response_code(400); + die(json_encode(array('error' => 'No filename provided'))); + } + + // Sanitize to prevent directory traversal + $filename = basename($filename); + $filepath = $iconDir.'/'.$filename; + + if (file_exists($filepath)) { + if (unlink($filepath)) { + echo json_encode(array('success' => true)); + } else { + http_response_code(500); + die(json_encode(array('error' => 'Failed to delete file'))); + } + } else { + http_response_code(404); + die(json_encode(array('error' => 'File not found'))); + } + exit; +} + +http_response_code(400); +die(json_encode(array('error' => 'Invalid action'))); diff --git a/ajax/medium_types.php b/ajax/medium_types.php new file mode 100755 index 0000000..261b9e3 --- /dev/null +++ b/ajax/medium_types.php @@ -0,0 +1,119 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$systemId = GETPOSTINT('system_id'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +$mediumType = new MediumType($db); + +switch ($action) { + case 'list': + // Get all medium types for a system (or all) + $types = $mediumType->fetchAllBySystem($systemId, 1); + + $result = array(); + foreach ($types as $t) { + $specs = $t->getAvailableSpecsArray(); + $result[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'category' => $t->category, + 'category_label' => $t->getCategoryLabel(), + 'fk_system' => $t->fk_system, + 'system_label' => $t->system_label, + 'default_spec' => $t->default_spec, + 'available_specs' => $specs, + 'color' => $t->color + ); + } + + $response['success'] = true; + $response['types'] = $result; + break; + + case 'list_grouped': + // Get types grouped by category + $grouped = $mediumType->fetchGroupedByCategory($systemId); + + $result = array(); + foreach ($grouped as $category => $types) { + $catTypes = array(); + foreach ($types as $t) { + $catTypes[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'default_spec' => $t->default_spec, + 'available_specs' => $t->getAvailableSpecsArray(), + 'color' => $t->color + ); + } + $result[] = array( + 'category' => $category, + 'category_label' => $types[0]->getCategoryLabel(), + 'types' => $catTypes + ); + } + + $response['success'] = true; + $response['groups'] = $result; + break; + + case 'get': + // Get single type details + $typeId = GETPOSTINT('type_id'); + if ($typeId > 0 && $mediumType->fetch($typeId) > 0) { + $response['success'] = true; + $response['type'] = array( + 'id' => $mediumType->id, + 'ref' => $mediumType->ref, + 'label' => $mediumType->label, + 'label_short' => $mediumType->label_short, + 'category' => $mediumType->category, + 'category_label' => $mediumType->getCategoryLabel(), + 'default_spec' => $mediumType->default_spec, + 'available_specs' => $mediumType->getAvailableSpecsArray(), + 'color' => $mediumType->color, + 'description' => $mediumType->description + ); + } else { + $response['error'] = $langs->trans('ErrorRecordNotFound'); + } + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/pwa_api.php b/ajax/pwa_api.php new file mode 100644 index 0000000..9fadc93 --- /dev/null +++ b/ajax/pwa_api.php @@ -0,0 +1,333 @@ + false, 'error' => 'Dolibarr not loaded'))); +} + +header('Content-Type: application/json; charset=UTF-8'); + +$response = array('success' => false); + +// Verify token +$token = GETPOST('token', 'none'); +if (empty($token)) { + echo json_encode(array('success' => false, 'error' => 'Nicht authentifiziert')); + exit; +} + +$tokenData = json_decode(base64_decode($token), true); +if (!$tokenData || empty($tokenData['user_id']) || empty($tokenData['expires']) || $tokenData['expires'] < time()) { + echo json_encode(array('success' => false, 'error' => 'Token ungültig oder abgelaufen')); + exit; +} + +// Verify hash +$expectedHash = md5($tokenData['user_id'] . $tokenData['login'] . getDolGlobalString('MAIN_SECURITY_SALT', 'defaultsalt')); +if ($tokenData['hash'] !== $expectedHash) { + echo json_encode(array('success' => false, 'error' => 'Token manipuliert')); + exit; +} + +// Load user +require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; +$user = new User($db); +$user->fetch($tokenData['user_id']); + +if ($user->id <= 0 || $user->statut != 1) { + echo json_encode(array('success' => false, 'error' => 'Benutzer nicht mehr aktiv')); + exit; +} + +// Check permission +if (!$user->hasRight('kundenkarte', 'read')) { + echo json_encode(array('success' => false, 'error' => 'Keine Berechtigung')); + exit; +} + +// Load required classes +require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/anlage.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipment.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmenttype.class.php'; + +$action = GETPOST('action', 'aZ09'); + +switch ($action) { + // ============================================ + // CUSTOMER SEARCH + // ============================================ + case 'search_customers': + $query = GETPOST('query', 'alphanohtml'); + if (strlen($query) < 2) { + $response['error'] = 'Mindestens 2 Zeichen'; + break; + } + + $sql = "SELECT s.rowid, s.nom as name, s.town"; + $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; + $sql .= " WHERE s.entity IN (".getEntity('societe').")"; + $sql .= " AND s.status = 1"; + $sql .= " AND (s.nom LIKE '%".$db->escape($query)."%'"; + $sql .= " OR s.name_alias LIKE '%".$db->escape($query)."%')"; + $sql .= " ORDER BY s.nom ASC"; + $sql .= " LIMIT 30"; + + $resql = $db->query($sql); + $customers = array(); + + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $customers[] = array( + 'id' => $obj->rowid, + 'name' => $obj->name, + 'town' => $obj->town + ); + } + } + + $response['success'] = true; + $response['customers'] = $customers; + break; + + // ============================================ + // GET ANLAGEN FOR CUSTOMER + // ============================================ + case 'get_anlagen': + $customerId = GETPOSTINT('customer_id'); + if ($customerId <= 0) { + $response['error'] = 'Keine Kunden-ID'; + break; + } + + $anlage = new Anlage($db); + $anlagen = $anlage->fetchAll('ASC', 'label', 0, 0, array('fk_soc' => $customerId)); + + $result = array(); + if (is_array($anlagen)) { + foreach ($anlagen as $a) { + $result[] = array( + 'id' => $a->id, + 'label' => $a->label, + 'has_editor' => !empty($a->schematic_editor_enabled) + ); + } + } + + $response['success'] = true; + $response['anlagen'] = $result; + break; + + // ============================================ + // GET ANLAGE DATA (Panels, Carriers, Equipment) + // ============================================ + case 'get_anlage_data': + $anlageId = GETPOSTINT('anlage_id'); + if ($anlageId <= 0) { + $response['error'] = 'Keine Anlagen-ID'; + break; + } + + // Load panels + $panel = new EquipmentPanel($db); + $panels = $panel->fetchByAnlage($anlageId); + $panelsData = array(); + foreach ($panels as $p) { + $panelsData[] = array( + 'id' => $p->id, + 'label' => $p->label, + 'position' => $p->position + ); + } + + // Load carriers + $carrier = new EquipmentCarrier($db); + $carriersData = array(); + foreach ($panels as $p) { + $p->fetchCarriers(); + foreach ($p->carriers as $c) { + $carriersData[] = array( + 'id' => $c->id, + 'fk_panel' => $c->fk_panel, + 'label' => $c->label, + 'total_te' => $c->total_te, + 'position' => $c->position + ); + } + } + + // Load equipment + $equipment = new Equipment($db); + $equipmentData = array(); + foreach ($carriersData as $c) { + $items = $equipment->fetchByCarrier($c['id']); + foreach ($items as $eq) { + $equipmentData[] = array( + 'id' => $eq->id, + 'fk_carrier' => $eq->fk_carrier, + 'fk_equipment_type' => $eq->fk_equipment_type, + 'label' => $eq->label, + 'position_te' => $eq->position_te, + 'width_te' => $eq->width_te, + 'field_values' => $eq->getFieldValues() + ); + } + } + + // Load equipment types + $eqType = new EquipmentType($db); + $types = $eqType->fetchAllBySystem(1, 1); // System 1 = Elektro, nur aktive + $typesData = array(); + foreach ($types as $t) { + $typesData[] = array( + 'id' => $t->id, + 'ref' => $t->ref, + 'label' => $t->label, + 'label_short' => $t->label_short, + 'width_te' => $t->width_te, + 'color' => $t->color + ); + } + + $response['success'] = true; + $response['panels'] = $panelsData; + $response['carriers'] = $carriersData; + $response['equipment'] = $equipmentData; + $response['types'] = $typesData; + break; + + // ============================================ + // CREATE PANEL + // ============================================ + case 'create_panel': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Keine Schreibberechtigung'; + break; + } + + $anlageId = GETPOSTINT('anlage_id'); + $label = GETPOST('label', 'alphanohtml'); + + if ($anlageId <= 0) { + $response['error'] = 'Keine Anlagen-ID'; + break; + } + + $panel = new EquipmentPanel($db); + $panel->fk_anlage = $anlageId; + $panel->label = $label ?: 'Feld'; + + $result = $panel->create($user); + if ($result > 0) { + $response['success'] = true; + $response['panel_id'] = $result; + } else { + $response['error'] = $panel->error ?: 'Fehler beim Anlegen'; + } + break; + + // ============================================ + // CREATE CARRIER + // ============================================ + case 'create_carrier': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Keine Schreibberechtigung'; + break; + } + + $panelId = GETPOSTINT('panel_id'); + $totalTe = GETPOSTINT('total_te') ?: 12; + $label = GETPOST('label', 'alphanohtml'); + + if ($panelId <= 0) { + $response['error'] = 'Keine Panel-ID'; + break; + } + + $carrier = new EquipmentCarrier($db); + $carrier->fk_panel = $panelId; + $carrier->label = $label ?: 'Hutschiene'; + $carrier->total_te = $totalTe; + + $result = $carrier->create($user); + if ($result > 0) { + $response['success'] = true; + $response['carrier_id'] = $result; + } else { + $response['error'] = $carrier->error ?: 'Fehler beim Anlegen'; + } + break; + + // ============================================ + // CREATE EQUIPMENT + // ============================================ + case 'create_equipment': + if (!$user->hasRight('kundenkarte', 'write')) { + $response['error'] = 'Keine Schreibberechtigung'; + break; + } + + $carrierId = GETPOSTINT('carrier_id'); + $typeId = GETPOSTINT('type_id'); + $label = GETPOST('label', 'alphanohtml'); + $positionTe = GETPOSTINT('position_te') ?: 1; + $fieldValues = GETPOST('field_values', 'nohtml'); + + if ($carrierId <= 0 || $typeId <= 0) { + $response['error'] = 'Carrier-ID und Typ-ID erforderlich'; + break; + } + + // Load type for width + $eqType = new EquipmentType($db); + $eqType->fetch($typeId); + + $equipment = new Equipment($db); + $equipment->fk_carrier = $carrierId; + $equipment->fk_equipment_type = $typeId; + $equipment->label = $label; + $equipment->position_te = $positionTe; + $equipment->width_te = $eqType->width_te ?: 1; + $equipment->field_values = $fieldValues; + + $result = $equipment->create($user); + if ($result > 0) { + $response['success'] = true; + $response['equipment_id'] = $result; + } else { + $response['error'] = $equipment->error ?: 'Fehler beim Anlegen'; + } + break; + + default: + $response['error'] = 'Unbekannte Aktion: ' . $action; +} + +echo json_encode($response); +$db->close(); diff --git a/ajax/tree_config.php b/ajax/tree_config.php new file mode 100755 index 0000000..e18eaf2 --- /dev/null +++ b/ajax/tree_config.php @@ -0,0 +1,107 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$action = GETPOST('action', 'aZ09'); +$systemId = GETPOSTINT('system_id'); + +$response = array('success' => false, 'error' => ''); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + $response['error'] = $langs->trans('ErrorPermissionDenied'); + echo json_encode($response); + exit; +} + +switch ($action) { + case 'get': + // Get tree config for a system + $defaultConfig = array( + 'show_ref' => true, + 'show_label' => true, + 'show_type' => true, + 'show_icon' => true, + 'show_status' => true, + 'show_fields' => false, + 'expand_default' => true, + 'indent_style' => 'lines' + ); + + if ($systemId > 0) { + $sql = "SELECT tree_display_config FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE rowid = ".(int)$systemId; + $resql = $db->query($sql); + if ($resql && $obj = $db->fetch_object($resql)) { + if (!empty($obj->tree_display_config)) { + $savedConfig = json_decode($obj->tree_display_config, true); + if (is_array($savedConfig)) { + $defaultConfig = array_merge($defaultConfig, $savedConfig); + } + } + } + } + + $response['success'] = true; + $response['config'] = $defaultConfig; + break; + + case 'list': + // Get all system configs + $sql = "SELECT rowid, code, label, tree_display_config FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position"; + $resql = $db->query($sql); + + $configs = array(); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $config = array( + 'show_ref' => true, + 'show_label' => true, + 'show_type' => true, + 'show_icon' => true, + 'show_status' => true, + 'show_fields' => false, + 'expand_default' => true, + 'indent_style' => 'lines' + ); + + if (!empty($obj->tree_display_config)) { + $savedConfig = json_decode($obj->tree_display_config, true); + if (is_array($savedConfig)) { + $config = array_merge($config, $savedConfig); + } + } + + $configs[$obj->rowid] = array( + 'id' => $obj->rowid, + 'code' => $obj->code, + 'label' => $obj->label, + 'config' => $config + ); + } + } + + $response['success'] = true; + $response['systems'] = $configs; + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/ajax/type_fields.php b/ajax/type_fields.php new file mode 100755 index 0000000..40230bf --- /dev/null +++ b/ajax/type_fields.php @@ -0,0 +1,61 @@ + array())); + exit; +} + +$type = new AnlageType($db); +if ($type->fetch($typeId) <= 0) { + echo json_encode(array('fields' => array())); + exit; +} + +$fields = $type->fetchFields(); + +// Get existing values if editing +$existingValues = array(); +if ($anlageId > 0) { + $anlage = new Anlage($db); + if ($anlage->fetch($anlageId) > 0) { + $existingValues = $anlage->getFieldValues(); + } +} + +$result = array('fields' => array()); + +foreach ($fields as $field) { + $fieldData = array( + 'code' => $field->field_code, + 'label' => $field->field_label, + 'type' => $field->field_type, + 'options' => $field->field_options, + 'required' => (int)$field->required === 1, + 'autocomplete' => (int)$field->enable_autocomplete === 1, + 'value' => isset($existingValues[$field->field_code]) ? $existingValues[$field->field_code] : '' + ); + $result['fields'][] = $fieldData; +} + +// Also include type_id for autocomplete +$result['type_id'] = $typeId; + +echo json_encode($result); diff --git a/anlage_connection.php b/anlage_connection.php new file mode 100755 index 0000000..0b20b00 --- /dev/null +++ b/anlage_connection.php @@ -0,0 +1,306 @@ +loadLangs(array('kundenkarte@kundenkarte')); + +$id = GETPOSTINT('id'); +$socId = GETPOSTINT('socid'); +$contactId = GETPOSTINT('contactid'); +$systemId = GETPOSTINT('system_id'); +$sourceId = GETPOSTINT('source_id'); +$action = GETPOST('action', 'aZ09'); + +// Security check +if (!$user->hasRight('kundenkarte', 'read')) { + accessforbidden(); +} + +$connection = new AnlageConnection($db); +$anlage = new Anlage($db); +$form = new Form($db); + +// Load existing connection +if ($id > 0) { + $result = $connection->fetch($id); + if ($result <= 0) { + setEventMessages($langs->trans('ErrorRecordNotFound'), null, 'errors'); + header('Location: '.DOL_URL_ROOT.'/societe/card.php?socid='.$socId); + exit; + } + // Get socId from source anlage if not provided + if (empty($socId)) { + $tmpAnlage = new Anlage($db); + if ($tmpAnlage->fetch($connection->fk_source) > 0) { + $socId = $tmpAnlage->fk_soc; + $systemId = $tmpAnlage->fk_system; + } + } +} + +// Redirect-URL: zurück zur Kontakt- oder Kunden-Anlagenansicht +if ($contactId > 0) { + $backUrl = dol_buildpath('/kundenkarte/tabs/contact_anlagen.php', 1).'?id='.$contactId.'&system='.$systemId; +} else { + $backUrl = dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId; +} + +/* + * Actions + */ + +if ($action == 'update' && $user->hasRight('kundenkarte', 'write')) { + $connection->fk_source = GETPOSTINT('fk_source'); + $connection->fk_target = GETPOSTINT('fk_target'); + $connection->label = GETPOST('label', 'alphanohtml'); + $connection->fk_medium_type = GETPOSTINT('fk_medium_type'); + $connection->medium_type_text = GETPOST('medium_type_text', 'alphanohtml'); + $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + $connection->medium_color = GETPOST('medium_color', 'alphanohtml'); + $connection->route_description = GETPOST('route_description', 'restricthtml'); + $connection->installation_date = GETPOST('installation_date', 'alpha'); + + if (empty($connection->fk_source) || empty($connection->fk_target)) { + setEventMessages($langs->trans('ErrorFieldRequired', 'Quelle/Ziel'), null, 'errors'); + } else { + $result = $connection->update($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$backUrl); + exit; + } else { + setEventMessages($connection->error, null, 'errors'); + } + } +} + +if ($action == 'add' && $user->hasRight('kundenkarte', 'write')) { + $connection->fk_source = GETPOSTINT('fk_source'); + $connection->fk_target = GETPOSTINT('fk_target'); + $connection->label = GETPOST('label', 'alphanohtml'); + $connection->fk_medium_type = GETPOSTINT('fk_medium_type'); + $connection->medium_type_text = GETPOST('medium_type_text', 'alphanohtml'); + $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml'); + $connection->medium_length = GETPOST('medium_length', 'alphanohtml'); + $connection->medium_color = GETPOST('medium_color', 'alphanohtml'); + $connection->route_description = GETPOST('route_description', 'restricthtml'); + $connection->installation_date = GETPOST('installation_date', 'alpha'); + $connection->status = 1; + + if (empty($connection->fk_source) || empty($connection->fk_target)) { + setEventMessages($langs->trans('ErrorFieldRequired', 'Quelle/Ziel'), null, 'errors'); + } else { + $result = $connection->create($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); + header('Location: '.$backUrl); + exit; + } else { + setEventMessages($connection->error, null, 'errors'); + } + } +} + +if ($action == 'delete' && $user->hasRight('kundenkarte', 'write')) { + $result = $connection->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + header('Location: '.$backUrl); + exit; + } else { + setEventMessages($connection->error, null, 'errors'); + } +} + +/* + * View + */ + +$title = $id > 0 ? 'Verbindung bearbeiten' : 'Neue Verbindung'; +llxHeader('', $title); + +// Gebäude-Typ-IDs ermitteln (Verbindungen nur zwischen Geräten, nicht Gebäuden) +$buildingTypeIds = array(); +$sqlBt = "SELECT t.rowid FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type t"; +$sqlBt .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system ts ON t.fk_system = ts.rowid"; +$sqlBt .= " WHERE ts.code = 'GLOBAL'"; +$resBt = $db->query($sqlBt); +if ($resBt) { + while ($btObj = $db->fetch_object($resBt)) { + $buildingTypeIds[] = (int) $btObj->rowid; + } +} + +// Alle Elemente für Dropdowns laden (OHNE System-Filter, da Kabel systemübergreifend sein können) +$anlagenList = array(); +if ($socId > 0) { + if ($contactId > 0) { + $tree = $anlage->fetchTreeByContact($socId, $contactId, 0); + } else { + $tree = $anlage->fetchTree($socId, 0); + } + // Baum flach machen - nur Geräte, Gebäude als Pfad-Kontext + $flattenTree = function($nodes, $path = '') use (&$flattenTree, &$anlagenList, &$buildingTypeIds) { + foreach ($nodes as $node) { + $isBuilding = in_array((int) $node->fk_anlage_type, $buildingTypeIds); + + if ($isBuilding) { + // Gebäude/Raum: nicht wählbar, aber Pfad als Kontext weitergeben + $newPath = $path ? $path.' > '.$node->label : $node->label; + if (!empty($node->children)) { + $flattenTree($node->children, $newPath); + } + } else { + // Gerät: in Liste aufnehmen mit Gebäude-Pfad als Kontext + $typeInfo = !empty($node->type_short) ? $node->type_short : (!empty($node->type_label) ? $node->type_label : ''); + $label = ''; + if (!empty($path)) { + $label = $path.' > '; + } + $label .= $node->label; + if (!empty($typeInfo)) { + $label .= ' ['.$typeInfo.']'; + } + $anlagenList[$node->id] = array( + 'label' => $label, + 'picto' => !empty($node->type_picto) ? $node->type_picto : 'fa-cube', + ); + // Rekursion in Geräte-Kinder + if (!empty($node->children)) { + $flattenTree($node->children, $path); + } + } + } + }; + $flattenTree($tree); +} + +// Load medium types +$mediumTypes = array(); +$sql = "SELECT rowid, label, category FROM ".MAIN_DB_PREFIX."kundenkarte_medium_type WHERE active = 1 ORDER BY category, label"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $mediumTypes[$obj->rowid] = $obj->label; + } +} + +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +print load_fiche_titre($title, '', 'object_kundenkarte@kundenkarte'); + +print ''; + +// Source +print ''; +print ''; + +// Target +print ''; +print ''; + +// Medium type +print ''; +print ''; + +// Medium type text (free text) +print ''; +print ''; + +// Medium spec +print ''; +print ''; + +// Length +print ''; +print ''; + +// Color +print ''; +print ''; + +// Label +print ''; +print ''; + +// Route description +print ''; +print ''; + +// Installation date +print ''; +print ''; + +print '
'.$langs->trans('Von (Quelle)').'
'.$langs->trans('Nach (Ziel)').'
'.$langs->trans('Kabeltyp').'
'.$langs->trans('Kabeltyp (Freitext)').'
'.$langs->trans('Querschnitt/Typ').'
'.$langs->trans('Länge').'
'.$langs->trans('Farbe').'
'.$langs->trans('Bezeichnung').'
'.$langs->trans('Verlegungsweg').'
'.$langs->trans('Installationsdatum').'
'; + +print '
'; +print ''; +print ' '.$langs->trans('Cancel').''; + +if ($id > 0 && $user->hasRight('kundenkarte', 'write')) { + print ' '.$langs->trans('Delete').''; +} +print '
'; + +print '
'; + +// Select2 mit Icons für Quelle/Ziel-Dropdowns +print ''; + +llxFooter(); +$db->close(); diff --git a/build/buildzip.php b/build/buildzip.php new file mode 100755 index 0000000..3508bbb --- /dev/null +++ b/build/buildzip.php @@ -0,0 +1,316 @@ +#!/usr/bin/env php -d memory_limit=256M + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + The goal of that php CLI script is to make zip package of your module + as an alternative to web "build zip" or "perl script makepack" +*/ + +// ============================================= configuration + +/** + * list of files & dirs of your module + * + * @var string[] + */ +$listOfModuleContent = [ + 'admin', + 'ajax', + 'backport', + 'class', + 'css', + 'COPYING', + 'core', + 'img', + 'js', + 'langs', + 'lib', + 'sql', + 'tpl', + '*.md', + '*.json', + '*.php', + 'modulebuilder.txt', +]; + +/** + * if you want to exclude some files from your zip + * + * @var string[] + */ +$exclude_list = [ + '/^.git$/', + '/.*js.map/', + '/DEV.md/' +]; + +// ============================================= end of configuration + +/** + * auto detect module name and version from file name + * + * @return (string|string)[] module name and module version + */ +function detectModule() +{ + $name = $version = ""; + $tab = glob("core/modules/mod*.class.php"); + if (count($tab) == 0) { + echo "[fail] Error on auto detect data : there is no mod*.class.php file into core/modules dir\n"; + exit(-1); + } + if (count($tab) == 1) { + $file = $tab[0]; + $pattern = "/.*mod(?.*)\.class\.php/"; + if (preg_match_all($pattern, $file, $matches)) { + $name = strtolower(reset($matches['mod'])); + } + + echo "extract data from $file\n"; + if (!file_exists($file) || $name == "") { + echo "[fail] Error on auto detect data\n"; + exit(-2); + } + } else { + echo "[fail] Error there is more than one mod*.class.php file into core/modules dir\n"; + exit(-3); + } + + //extract version from file + $contents = file_get_contents($file); + $pattern = "/^.*this->version\s*=\s*'(?.*)'\s*;.*\$/m"; + + // search, and store all matching occurrences in $matches + if (preg_match_all($pattern, $contents, $matches)) { + $version = reset($matches['version']); + } + + if (version_compare($version, '0.0.1', '>=') != 1) { + echo "[fail] Error auto extract version fail\n"; + exit(-4); + } + + echo "module name = $name, version = $version\n"; + return [(string) $name, (string) $version]; +} + +/** + * delete recursively a directory + * + * @param string $dir dir path to delete + * + * @return bool true on success or false on failure. + */ +function delTree($dir) +{ + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : secureUnlink("$dir/$file"); + } + return rmdir($dir); +} + + +/** + * do a secure delete file/dir with double check + * (don't trust unlink return) + * + * @param string $path full path to delete + * + * @return bool true on success ($path does not exists at the end of process), else exit + */ +function secureUnlink($path) +{ + if (file_exists($path)) { + if (unlink($path)) { + //then check if really deleted + clearstatcache(); + if (file_exists($path)) { // @phpstan-ignore-line + echo "[fail] unlink of $path fail !\n"; + exit(-5); + } + } else { + echo "[fail] unlink of $path fail !\n"; + exit(-6); + } + } + return true; +} + +/** + * create a directory and check if dir exists + * + * @param string $path path to make + * + * @return bool true on success ($path exists at the end of process), else exit + */ +function mkdirAndCheck($path) +{ + if (mkdir($path)) { + clearstatcache(); + if (is_dir($path)) { + return true; + } + } + echo "[fail] Error on $path (mkdir)\n"; + exit(7); +} + +/** + * check if that filename is concerned by exclude filter + * + * @param string $filename file name to check + * + * @return bool true if file is in excluded list + */ +function is_excluded($filename) +{ + global $exclude_list; + $count = 0; + $notused = preg_filter($exclude_list, '1', $filename, -1, $count); + if ($count > 0) { + echo " - exclude $filename\n"; + return true; + } + return false; +} + +/** + * recursive copy files & dirs + * + * @param string $src source dir + * @param string $dst target dir + * + * @return bool true on success or false on failure. + */ +function rcopy($src, $dst) +{ + if (is_dir($src)) { + // Make the destination directory if not exist + mkdirAndCheck($dst); + // open the source directory + $dir = opendir($src); + + // Loop through the files in source directory + while ($file = readdir($dir)) { + if (($file != '.') && ($file != '..')) { + if (is_dir($src . '/' . $file)) { + // Recursively calling custom copy function + // for sub directory + if (!rcopy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } else { + if (!is_excluded($file)) { + if (!copy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } + } + } + } + closedir($dir); + } elseif (is_file($src)) { + if (!is_excluded($src)) { + if (!copy($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * build a zip file with only php code and no external depends + * on "zip" exec for example + * + * @param string $folder folder to use as zip root + * @param ZipArchive $zip zip object (ZipArchive) + * @param string $root relative root path into the zip + * + * @return bool true on success or false on failure. + */ +function zipDir($folder, &$zip, $root = "") +{ + foreach (new \DirectoryIterator($folder) as $f) { + if ($f->isDot()) { + continue; + } //skip . .. + $src = $folder . '/' . $f; + $dst = substr($f->getPathname(), strlen($root)); + if ($f->isDir()) { + if ($zip->addEmptyDir($dst)) { + if (zipDir($src, $zip, $root)) { + continue; + } else { + return false; + } + } else { + return false; + } + } + if ($f->isFile()) { + if (! $zip->addFile($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * main part of script + */ + +list($mod, $version) = detectModule(); +$outzip = sys_get_temp_dir() . "/module_" . $mod . "-" . $version . ".zip"; +if (file_exists($outzip)) { + secureUnlink($outzip); +} + +//copy all sources into system temp directory +$tmpdir = tempnam(sys_get_temp_dir(), $mod . "-module"); +secureUnlink($tmpdir); +mkdirAndCheck($tmpdir); +$dst = $tmpdir . "/" . $mod; +mkdirAndCheck($dst); + +foreach ($listOfModuleContent as $moduleContent) { + foreach (glob($moduleContent) as $entry) { + if (!rcopy($entry, $dst . '/' . $entry)) { + echo "[fail] Error on copy " . $entry . " to " . $dst . "/" . $entry . "\n"; + echo "Please take time to analyze the problem and fix the bug\n"; + exit(-8); + } + } +} + +$z = new ZipArchive(); +$z->open($outzip, ZIPARCHIVE::CREATE); +zipDir($tmpdir, $z, $tmpdir . '/'); +$z->close(); +delTree($tmpdir); +if (file_exists($outzip)) { + echo "[success] module archive is ready : $outzip ...\n"; +} else { + echo "[fail] build zip error\n"; + exit(-9); +} diff --git a/build/makepack-kundenkarte.conf b/build/makepack-kundenkarte.conf new file mode 100755 index 0000000..16dc1e7 --- /dev/null +++ b/build/makepack-kundenkarte.conf @@ -0,0 +1,11 @@ +# Your module name here +# +# Goal: Goal of module +# Version: +# Author: Copyright - +# License: GPLv3 +# Install: Just unpack content of module package in Dolibarr directory. +# Setup: Go on Dolibarr setup - modules to enable module. +# +# Files in module +mymodule/ \ No newline at end of file diff --git a/class/anlage.class.php b/class/anlage.class.php new file mode 100755 index 0000000..603c4df --- /dev/null +++ b/class/anlage.class.php @@ -0,0 +1,763 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user, $notrigger = false) + { + global $conf; + + $error = 0; + $now = dol_now(); + + // Check parameters + if (empty($this->fk_soc) || empty($this->fk_anlage_type) || empty($this->label)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Note: Circular reference check not needed on create since element doesn't exist yet + + // Calculate level + $this->level = 0; + if ($this->fk_parent > 0) { + $parent = new Anlage($this->db); + if ($parent->fetch($this->fk_parent) > 0) { + $this->level = $parent->level + 1; + } + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, fk_soc, fk_contact, fk_anlage_type, fk_parent, fk_system,"; + $sql .= " manufacturer, model, serial_number, power_rating, field_values,"; + $sql .= " location, installation_date, warranty_until,"; + $sql .= " rang, level, note_private, note_public, status,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".($this->ref ? "'".$this->db->escape($this->ref)."'" : "NULL"); + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".((int) $this->fk_soc); + $sql .= ", ".($this->fk_contact > 0 ? ((int) $this->fk_contact) : "NULL"); + $sql .= ", ".((int) $this->fk_anlage_type); + $sql .= ", ".((int) ($this->fk_parent > 0 ? $this->fk_parent : 0)); + $sql .= ", ".((int) $this->fk_system); + $sql .= ", ".($this->manufacturer ? "'".$this->db->escape($this->manufacturer)."'" : "NULL"); + $sql .= ", ".($this->model ? "'".$this->db->escape($this->model)."'" : "NULL"); + $sql .= ", ".($this->serial_number ? "'".$this->db->escape($this->serial_number)."'" : "NULL"); + $sql .= ", ".($this->power_rating ? "'".$this->db->escape($this->power_rating)."'" : "NULL"); + $sql .= ", ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL"); + $sql .= ", ".($this->location ? "'".$this->db->escape($this->location)."'" : "NULL"); + $sql .= ", ".($this->installation_date ? "'".$this->db->idate($this->installation_date)."'" : "NULL"); + $sql .= ", ".($this->warranty_until ? "'".$this->db->idate($this->warranty_until)."'" : "NULL"); + $sql .= ", ".((int) $this->rang); + $sql .= ", ".((int) $this->level); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); + $sql .= ", ".((int) ($this->status !== null ? $this->status : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + $this->date_creation = $now; + $this->fk_user_creat = $user->id; + + // Create directory for files + $this->createFileDirectory(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + global $conf; + + $sql = "SELECT a.*, t.label as type_label, t.label_short as type_short, t.picto as type_picto,"; + $sql .= " s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage_type as t ON a.fk_anlage_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON a.fk_system = s.rowid"; + $sql .= " WHERE a.rowid = ".((int) $id); + $sql .= " AND a.entity = ".((int) $conf->entity); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + $this->setFromObject($obj); + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Set object properties from database object + * + * @param object $obj Database object + */ + private function setFromObject($obj) + { + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->fk_soc = $obj->fk_soc; + $this->fk_contact = isset($obj->fk_contact) ? $obj->fk_contact : null; + $this->fk_anlage_type = $obj->fk_anlage_type; + $this->fk_parent = $obj->fk_parent; + $this->fk_system = $obj->fk_system; + $this->fk_building_node = isset($obj->fk_building_node) ? (int) $obj->fk_building_node : 0; + + $this->manufacturer = $obj->manufacturer; + $this->model = $obj->model; + $this->serial_number = $obj->serial_number; + $this->power_rating = $obj->power_rating; + $this->field_values = $obj->field_values; + + $this->location = $obj->location; + $this->installation_date = $this->db->jdate($obj->installation_date); + $this->warranty_until = $this->db->jdate($obj->warranty_until); + + $this->rang = $obj->rang; + $this->level = $obj->level; + $this->note_private = $obj->note_private; + $this->note_public = $obj->note_public; + $this->status = $obj->status; + + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->tms = $this->db->jdate($obj->tms); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + // Type info + $this->type_label = $obj->type_label; + $this->type_short = $obj->type_short; + $this->type_picto = $obj->type_picto; + + // System info + $this->system_label = $obj->system_label; + $this->system_code = $obj->system_code; + + // File counts (from tree query) + $this->image_count = isset($obj->image_count) ? (int) $obj->image_count : 0; + $this->doc_count = isset($obj->doc_count) ? (int) $obj->doc_count : 0; + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user, $notrigger = false) + { + $error = 0; + + // Check for circular reference + if ($this->fk_parent > 0 && $this->wouldCreateCircularReference($this->fk_parent)) { + $this->error = 'ErrorCircularReference'; + $this->errors[] = 'Das Element kann nicht unter sich selbst oder einem seiner Unterelemente platziert werden.'; + return -2; + } + + // Recalculate level if parent changed + $this->level = 0; + if ($this->fk_parent > 0) { + $parent = new Anlage($this->db); + if ($parent->fetch($this->fk_parent) > 0) { + $this->level = $parent->level + 1; + } + } + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = ".($this->ref ? "'".$this->db->escape($this->ref)."'" : "NULL"); + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", fk_anlage_type = ".((int) $this->fk_anlage_type); + $sql .= ", fk_parent = ".((int) ($this->fk_parent > 0 ? $this->fk_parent : 0)); + $sql .= ", fk_system = ".((int) $this->fk_system); + $sql .= ", manufacturer = ".($this->manufacturer ? "'".$this->db->escape($this->manufacturer)."'" : "NULL"); + $sql .= ", model = ".($this->model ? "'".$this->db->escape($this->model)."'" : "NULL"); + $sql .= ", serial_number = ".($this->serial_number ? "'".$this->db->escape($this->serial_number)."'" : "NULL"); + $sql .= ", power_rating = ".($this->power_rating ? "'".$this->db->escape($this->power_rating)."'" : "NULL"); + $sql .= ", field_values = ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL"); + $sql .= ", location = ".($this->location ? "'".$this->db->escape($this->location)."'" : "NULL"); + $sql .= ", installation_date = ".($this->installation_date ? "'".$this->db->idate($this->installation_date)."'" : "NULL"); + $sql .= ", warranty_until = ".($this->warranty_until ? "'".$this->db->idate($this->warranty_until)."'" : "NULL"); + $sql .= ", rang = ".((int) $this->rang); + $sql .= ", level = ".((int) $this->level); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Reihenfolge (rang) für mehrere Elemente aktualisieren + * + * @param array $ids Array von Anlage-IDs in gewünschter Reihenfolge + * @return int 1 bei Erfolg, <0 bei Fehler + */ + public function updateRangs($ids) + { + $this->db->begin(); + $error = 0; + + foreach ($ids as $rang => $id) { + $sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage"; + $sql .= " SET rang = ".((int) $rang); + $sql .= " WHERE rowid = ".((int) $id); + if (!$this->db->query($sql)) { + $error++; + break; + } + } + + if ($error) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + return 1; + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user, $notrigger = false) + { + $error = 0; + + $this->db->begin(); + + // First delete all children recursively + $children = $this->fetchChildren($this->id); + foreach ($children as $child) { + $childObj = new Anlage($this->db); + if ($childObj->fetch($child->id) > 0) { + $result = $childObj->delete($user, $notrigger); + if ($result < 0) { + $error++; + $this->errors = array_merge($this->errors, $childObj->errors); + } + } + } + + if (!$error) { + // Delete files + $this->deleteFileDirectory(); + + // Delete file records + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files WHERE fk_anlage = ".((int) $this->id); + $this->db->query($sql); + + // Delete the element + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch children of an element + * + * @param int $parentId Parent ID (0 for root elements) + * @param int $socid Customer ID (required for root elements) + * @param int $systemId System ID (optional filter) + * @return array Array of Anlage objects + */ + public function fetchChildren($parentId = 0, $socid = 0, $systemId = 0) + { + global $conf; + + $results = array(); + + $sql = "SELECT a.*, t.label as type_label, t.label_short as type_short, t.picto as type_picto,"; + $sql .= " s.label as system_label, s.code as system_code,"; + // Count images + $sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type = 'image') as image_count,"; + // Count documents (pdf + document) + $sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type IN ('pdf', 'document')) as doc_count"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage_type as t ON a.fk_anlage_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON a.fk_system = s.rowid"; + $sql .= " WHERE a.fk_parent = ".((int) $parentId); + $sql .= " AND a.entity = ".((int) $conf->entity); + $sql .= " AND a.status = 1"; + + if ($parentId == 0 && $socid > 0) { + $sql .= " AND a.fk_soc = ".((int) $socid); + // Only show elements without contact assignment (thirdparty-level) + $sql .= " AND (a.fk_contact IS NULL OR a.fk_contact = 0)"; + } + if ($systemId > 0) { + $sql .= " AND a.fk_system = ".((int) $systemId); + } + + $sql .= " ORDER BY a.rang ASC, a.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $anlage = new Anlage($this->db); + $anlage->setFromObject($obj); + $results[] = $anlage; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch full tree for a customer and system + * + * @param int $socid Customer ID + * @param int $systemId System ID + * @return array Tree structure + */ + public function fetchTree($socid, $systemId) + { + $tree = array(); + $roots = $this->fetchChildren(0, $socid, $systemId); + + foreach ($roots as $root) { + $root->children = $this->fetchChildrenRecursive($root->id); + $tree[] = $root; + } + + return $tree; + } + + /** + * Fetch children recursively + * + * @param int $parentId Parent ID + * @return array Array of Anlage objects with children + */ + private function fetchChildrenRecursive($parentId) + { + $children = $this->fetchChildren($parentId); + foreach ($children as $child) { + $child->children = $this->fetchChildrenRecursive($child->id); + } + return $children; + } + + /** + * Get the file storage directory path + * + * @return string Directory path + */ + public function getFileDirectory() + { + global $conf; + return $conf->kundenkarte->dir_output.'/anlagen/'.$this->fk_soc.'/'.$this->id; + } + + /** + * Create the file directory + * + * @return bool + */ + public function createFileDirectory() + { + $dir = $this->getFileDirectory(); + if (!is_dir($dir)) { + return dol_mkdir($dir); + } + return true; + } + + /** + * Delete the file directory + * + * @return bool + */ + public function deleteFileDirectory() + { + $dir = $this->getFileDirectory(); + if (is_dir($dir)) { + return dol_delete_dir_recursive($dir); + } + return true; + } + + /** + * Get decoded field values + * + * @return array + */ + public function getFieldValues() + { + if (empty($this->field_values)) { + return array(); + } + $values = json_decode($this->field_values, true); + return is_array($values) ? $values : array(); + } + + /** + * Set field values from array + * + * @param array $values Field values + */ + public function setFieldValues($values) + { + $this->field_values = json_encode($values); + } + + /** + * Get a specific field value + * + * @param string $fieldCode Field code + * @return mixed|null + */ + public function getFieldValue($fieldCode) + { + $values = $this->getFieldValues(); + return isset($values[$fieldCode]) ? $values[$fieldCode] : null; + } + + /** + * Check if setting a parent would create a circular reference + * + * @param int $newParentId The proposed new parent ID + * @return bool True if circular reference would be created, false otherwise + */ + public function wouldCreateCircularReference($newParentId) + { + if (empty($this->id) || empty($newParentId)) { + return false; + } + + // Cannot be own parent + if ($newParentId == $this->id) { + return true; + } + + // Check if newParentId is a descendant of this element + return $this->isDescendant($newParentId, $this->id); + } + + /** + * Check if an element is a descendant of another + * + * @param int $elementId Element to check + * @param int $ancestorId Potential ancestor + * @param int $maxDepth Maximum depth to check (prevent infinite loops) + * @return bool True if elementId is a descendant of ancestorId + */ + private function isDescendant($elementId, $ancestorId, $maxDepth = 50) + { + if ($maxDepth <= 0) { + return true; // Safety: assume circular if too deep + } + + // Get all children of ancestorId + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_parent = ".((int) $ancestorId); + $sql .= " AND status = 1"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + if ($obj->rowid == $elementId) { + return true; + } + // Recursively check children + if ($this->isDescendant($elementId, $obj->rowid, $maxDepth - 1)) { + return true; + } + } + $this->db->free($resql); + } + + return false; + } + + /** + * Get all ancestor IDs of this element + * + * @param int $maxDepth Maximum depth to check + * @return array Array of ancestor IDs + */ + public function getAncestorIds($maxDepth = 50) + { + $ancestors = array(); + $currentParentId = $this->fk_parent; + $depth = 0; + + while ($currentParentId > 0 && $depth < $maxDepth) { + $ancestors[] = $currentParentId; + + $sql = "SELECT fk_parent FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".((int) $currentParentId); + + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $currentParentId = $obj->fk_parent; + $this->db->free($resql); + } else { + break; + } + $depth++; + } + + return $ancestors; + } + + /** + * Get info for tree display + * + * @return string + */ + public function getTreeInfo() + { + $info = array(); + + // Get type fields that should show in tree + $sql = "SELECT field_code, field_label FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + $sql .= " WHERE fk_anlage_type = ".((int) $this->fk_anlage_type); + $sql .= " AND show_in_tree = 1 AND active = 1"; + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + $values = $this->getFieldValues(); + while ($obj = $this->db->fetch_object($resql)) { + if (isset($values[$obj->field_code]) && $values[$obj->field_code] !== '') { + $info[] = $values[$obj->field_code]; + } + } + $this->db->free($resql); + } + + // Add common fields + if ($this->manufacturer) { + $info[] = $this->manufacturer; + } + if ($this->power_rating) { + $info[] = $this->power_rating; + } + + return implode(', ', $info); + } + + /** + * Fetch full tree for a contact and system + * + * @param int $socid Customer ID + * @param int $contactid Contact ID + * @param int $systemId System ID + * @return array Tree structure + */ + public function fetchTreeByContact($socid, $contactid, $systemId) + { + $tree = array(); + $roots = $this->fetchChildrenByContact(0, $socid, $contactid, $systemId); + + foreach ($roots as $root) { + $root->children = $this->fetchChildrenByContactRecursive($root->id, $socid, $contactid); + $tree[] = $root; + } + + return $tree; + } + + /** + * Fetch children of an element filtered by contact + * + * @param int $parentId Parent ID (0 for root elements) + * @param int $socid Customer ID + * @param int $contactid Contact ID + * @param int $systemId System ID (optional filter) + * @return array Array of Anlage objects + */ + public function fetchChildrenByContact($parentId = 0, $socid = 0, $contactid = 0, $systemId = 0) + { + global $conf; + + $results = array(); + + $sql = "SELECT a.*, t.label as type_label, t.label_short as type_short, t.picto as type_picto,"; + $sql .= " s.label as system_label, s.code as system_code,"; + // Count images + $sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type = 'image') as image_count,"; + // Count documents (pdf + document) + $sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type IN ('pdf', 'document')) as doc_count"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage_type as t ON a.fk_anlage_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON a.fk_system = s.rowid"; + $sql .= " WHERE a.fk_parent = ".((int) $parentId); + $sql .= " AND a.entity = ".((int) $conf->entity); + $sql .= " AND a.status = 1"; + + if ($parentId == 0) { + $sql .= " AND a.fk_soc = ".((int) $socid); + $sql .= " AND a.fk_contact = ".((int) $contactid); + } + if ($systemId > 0) { + $sql .= " AND a.fk_system = ".((int) $systemId); + } + + $sql .= " ORDER BY a.rang ASC, a.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $anlage = new Anlage($this->db); + $anlage->setFromObject($obj); + $results[] = $anlage; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch children recursively for contact + * + * @param int $parentId Parent ID + * @param int $socid Customer ID + * @param int $contactid Contact ID + * @return array Array of Anlage objects with children + */ + private function fetchChildrenByContactRecursive($parentId, $socid, $contactid) + { + $children = $this->fetchChildrenByContact($parentId, $socid, $contactid); + foreach ($children as $child) { + $child->children = $this->fetchChildrenByContactRecursive($child->id, $socid, $contactid); + } + return $children; + } +} diff --git a/class/anlagebackup.class.php b/class/anlagebackup.class.php new file mode 100755 index 0000000..50d52fc --- /dev/null +++ b/class/anlagebackup.class.php @@ -0,0 +1,558 @@ +db = $db; + } + + /** + * Create a full backup + * + * @param bool $includeFiles Include uploaded files in backup + * @return string|false Path to backup file or false on error + */ + public function createBackup($includeFiles = true) + { + global $conf; + + $backupDir = $conf->kundenkarte->dir_output.'/backups'; + if (!is_dir($backupDir)) { + dol_mkdir($backupDir); + } + + $timestamp = date('Y-m-d_H-i-s'); + $backupName = 'kundenkarte_backup_'.$timestamp; + $tempDir = $backupDir.'/'.$backupName; + + if (!dol_mkdir($tempDir)) { + $this->error = 'Cannot create backup directory'; + return false; + } + + // Export database tables + $dbData = $this->exportDatabaseTables(); + if ($dbData === false) { + return false; + } + + // Save database export as JSON + $dbFile = $tempDir.'/database.json'; + if (file_put_contents($dbFile, json_encode($dbData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) === false) { + $this->error = 'Cannot write database file'; + return false; + } + + // Create metadata file + $metadata = array( + 'version' => '3.6.0', + 'created' => date('Y-m-d H:i:s'), + 'tables' => array_keys($dbData), + 'record_counts' => array(), + 'includes_files' => $includeFiles, + ); + foreach ($dbData as $table => $records) { + $metadata['record_counts'][$table] = count($records); + } + file_put_contents($tempDir.'/metadata.json', json_encode($metadata, JSON_PRETTY_PRINT)); + + // Copy uploaded files if requested + if ($includeFiles) { + $filesDir = $conf->kundenkarte->dir_output.'/anlagen'; + if (is_dir($filesDir)) { + $this->copyDirectory($filesDir, $tempDir.'/files'); + } + } + + // Create ZIP archive + $zipFile = $backupDir.'/'.$backupName.'.zip'; + if (!$this->createZipArchive($tempDir, $zipFile)) { + $this->error = 'Cannot create ZIP archive'; + return false; + } + + // Clean up temp directory + $this->deleteDirectory($tempDir); + + return $zipFile; + } + + /** + * Export all database tables + * + * @return array|false Array of table data or false on error + */ + private function exportDatabaseTables() + { + global $conf; + + $data = array(); + + foreach ($this->tables as $table) { + $fullTable = MAIN_DB_PREFIX.$table; + + // Check if table exists + $sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'"; + $resql = $this->db->query($sql); + if (!$resql || $this->db->num_rows($resql) == 0) { + continue; // Skip non-existent tables + } + + $records = array(); + $sql = "SELECT * FROM ".$fullTable; + $sql .= " WHERE entity = ".((int) $conf->entity); + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_array($resql)) { + $records[] = $obj; + } + $this->db->free($resql); + } + + $data[$table] = $records; + } + + return $data; + } + + /** + * Restore from a backup file + * + * @param string $backupFile Path to backup ZIP file + * @param bool $clearExisting Clear existing data before restore + * @return bool True on success, false on error + */ + public function restoreBackup($backupFile, $clearExisting = false) + { + global $conf, $user; + + if (!file_exists($backupFile)) { + $this->error = 'Backup file not found'; + return false; + } + + // Create temp directory for extraction + $tempDir = $conf->kundenkarte->dir_output.'/backups/restore_'.uniqid(); + if (!dol_mkdir($tempDir)) { + $this->error = 'Cannot create temp directory'; + return false; + } + + // Extract ZIP + $zip = new ZipArchive(); + if ($zip->open($backupFile) !== true) { + $this->error = 'Cannot open backup file'; + return false; + } + $zip->extractTo($tempDir); + $zip->close(); + + // Read metadata + $metadataFile = $tempDir.'/metadata.json'; + if (!file_exists($metadataFile)) { + $this->error = 'Invalid backup: metadata.json not found'; + $this->deleteDirectory($tempDir); + return false; + } + $metadata = json_decode(file_get_contents($metadataFile), true); + + // Read database data + $dbFile = $tempDir.'/database.json'; + if (!file_exists($dbFile)) { + $this->error = 'Invalid backup: database.json not found'; + $this->deleteDirectory($tempDir); + return false; + } + $dbData = json_decode(file_get_contents($dbFile), true); + + $this->db->begin(); + + try { + // Clear existing data if requested + if ($clearExisting) { + $this->clearExistingData(); + } + + // Import database tables (in correct order) + foreach ($this->tables as $table) { + if (isset($dbData[$table])) { + $this->importTable($table, $dbData[$table]); + } + } + + // Restore files if included + if (!empty($metadata['includes_files']) && is_dir($tempDir.'/files')) { + $filesDir = $conf->kundenkarte->dir_output.'/anlagen'; + if (!is_dir($filesDir)) { + dol_mkdir($filesDir); + } + $this->copyDirectory($tempDir.'/files', $filesDir); + } + + $this->db->commit(); + } catch (Exception $e) { + $this->db->rollback(); + $this->error = $e->getMessage(); + $this->deleteDirectory($tempDir); + return false; + } + + // Clean up + $this->deleteDirectory($tempDir); + + return true; + } + + /** + * Clear existing data for this entity + */ + private function clearExistingData() + { + global $conf; + + // Delete in reverse order to respect foreign keys + $reverseTables = array_reverse($this->tables); + + foreach ($reverseTables as $table) { + $fullTable = MAIN_DB_PREFIX.$table; + + // Check if table exists + $sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'"; + $resql = $this->db->query($sql); + if (!$resql || $this->db->num_rows($resql) == 0) { + continue; + } + + $sql = "DELETE FROM ".$fullTable." WHERE entity = ".((int) $conf->entity); + $this->db->query($sql); + } + } + + /** + * Import data into a table + * + * @param string $table Table name (without prefix) + * @param array $records Array of records + */ + private function importTable($table, $records) + { + global $conf; + + if (empty($records)) { + return; + } + + $fullTable = MAIN_DB_PREFIX.$table; + + // Check if table exists + $sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'"; + $resql = $this->db->query($sql); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Get column info + $columns = array(); + $sql = "SHOW COLUMNS FROM ".$fullTable; + $resql = $this->db->query($sql); + while ($obj = $this->db->fetch_object($resql)) { + $columns[] = $obj->Field; + } + + foreach ($records as $record) { + // Build insert statement + $fields = array(); + $values = array(); + + foreach ($record as $field => $value) { + if (!in_array($field, $columns)) { + continue; // Skip unknown columns + } + + $fields[] = $field; + + if ($value === null) { + $values[] = 'NULL'; + } elseif (is_numeric($value)) { + $values[] = $value; + } else { + $values[] = "'".$this->db->escape($value)."'"; + } + } + + if (empty($fields)) { + continue; + } + + $sql = "INSERT INTO ".$fullTable." (".implode(', ', $fields).") VALUES (".implode(', ', $values).")"; + $sql .= " ON DUPLICATE KEY UPDATE "; + + $updates = array(); + foreach ($fields as $i => $field) { + if ($field != 'rowid') { + $updates[] = $field." = ".$values[$i]; + } + } + $sql .= implode(', ', $updates); + + if (!$this->db->query($sql)) { + throw new Exception('Error importing '.$table.': '.$this->db->lasterror()); + } + } + } + + /** + * Get list of available backups + * + * @return array Array of backup info + */ + public function getBackupList() + { + global $conf; + + $backups = array(); + $backupDir = $conf->kundenkarte->dir_output.'/backups'; + + if (!is_dir($backupDir)) { + return $backups; + } + + $files = glob($backupDir.'/kundenkarte_backup_*.zip'); + if ($files) { + foreach ($files as $file) { + $filename = basename($file); + // Extract date from filename + if (preg_match('/kundenkarte_backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip/', $filename, $matches)) { + $date = str_replace('_', ' ', $matches[1]); + $date = str_replace('-', ':', substr($date, 11)); + $date = substr($matches[1], 0, 10).' '.$date; + + $backups[] = array( + 'file' => $file, + 'filename' => $filename, + 'date' => $date, + 'size' => filesize($file), + ); + } + } + } + + // Sort by date descending + usort($backups, function ($a, $b) { + return strcmp($b['date'], $a['date']); + }); + + return $backups; + } + + /** + * Delete a backup file + * + * @param string $filename Backup filename + * @return bool + */ + public function deleteBackup($filename) + { + global $conf; + + $file = $conf->kundenkarte->dir_output.'/backups/'.basename($filename); + if (file_exists($file) && strpos($filename, 'kundenkarte_backup_') === 0) { + return unlink($file); + } + return false; + } + + /** + * Copy directory recursively + * + * @param string $src Source directory + * @param string $dst Destination directory + */ + private function copyDirectory($src, $dst) + { + if (!is_dir($dst)) { + dol_mkdir($dst); + } + + $dir = opendir($src); + while (($file = readdir($dir)) !== false) { + if ($file == '.' || $file == '..') { + continue; + } + + $srcFile = $src.'/'.$file; + $dstFile = $dst.'/'.$file; + + if (is_dir($srcFile)) { + $this->copyDirectory($srcFile, $dstFile); + } else { + copy($srcFile, $dstFile); + } + } + closedir($dir); + } + + /** + * Delete directory recursively + * + * @param string $dir Directory path + */ + private function deleteDirectory($dir) + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + $path = $dir.'/'.$file; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * Create ZIP archive from directory + * + * @param string $sourceDir Source directory + * @param string $zipFile Target ZIP file + * @return bool + */ + private function createZipArchive($sourceDir, $zipFile) + { + $zip = new ZipArchive(); + if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + $sourceDir = realpath($sourceDir); + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceDir), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (!$file->isDir()) { + $filePath = $file->getRealPath(); + $relativePath = substr($filePath, strlen($sourceDir) + 1); + $zip->addFile($filePath, $relativePath); + } + } + + return $zip->close(); + } + + /** + * Get backup statistics + * + * @return array Statistics array + */ + public function getStatistics() + { + global $conf; + + $stats = array( + 'total_anlagen' => 0, + 'total_files' => 0, + 'total_connections' => 0, + 'total_customers' => 0, + 'files_size' => 0, + ); + + // Count anlagen + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity); + $resql = $this->db->query($sql); + if ($resql && $obj = $this->db->fetch_object($resql)) { + $stats['total_anlagen'] = $obj->cnt; + } + + // Count files + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files WHERE entity = ".((int) $conf->entity); + $resql = $this->db->query($sql); + if ($resql && $obj = $this->db->fetch_object($resql)) { + $stats['total_files'] = $obj->cnt; + } + + // Count connections + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_connection WHERE entity = ".((int) $conf->entity); + $resql = $this->db->query($sql); + if ($resql && $obj = $this->db->fetch_object($resql)) { + $stats['total_connections'] = $obj->cnt; + } + + // Count customers with anlagen + $sql = "SELECT COUNT(DISTINCT fk_soc) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity); + $resql = $this->db->query($sql); + if ($resql && $obj = $this->db->fetch_object($resql)) { + $stats['total_customers'] = $obj->cnt; + } + + // Calculate files size + $filesDir = $conf->kundenkarte->dir_output.'/anlagen'; + if (is_dir($filesDir)) { + $stats['files_size'] = $this->getDirectorySize($filesDir); + } + + return $stats; + } + + /** + * Get directory size recursively + * + * @param string $dir Directory path + * @return int Size in bytes + */ + private function getDirectorySize($dir) + { + $size = 0; + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file) { + if ($file->isFile()) { + $size += $file->getSize(); + } + } + return $size; + } +} diff --git a/class/anlageconnection.class.php b/class/anlageconnection.class.php new file mode 100755 index 0000000..1c9978b --- /dev/null +++ b/class/anlageconnection.class.php @@ -0,0 +1,385 @@ +db = $db; + } + + /** + * Create connection + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + $this->db->begin(); + + // installation_date als DATE-Feld (YYYY-MM-DD String) sicher escapen + $installDateSQL = "NULL"; + if ($this->installation_date) { + $installDateSQL = "'".$this->db->escape($this->installation_date)."'"; + } + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_source, fk_target, label,"; + $sql .= "fk_medium_type, medium_type_text, medium_spec, medium_length, medium_color,"; + $sql .= "route_description, installation_date, status,"; + $sql .= "note_private, note_public, date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= (int)$conf->entity; + $sql .= ", ".(int)$this->fk_source; + $sql .= ", ".(int)$this->fk_target; + $sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", ".($this->fk_medium_type > 0 ? (int)$this->fk_medium_type : "NULL"); + $sql .= ", ".($this->medium_type_text ? "'".$this->db->escape($this->medium_type_text)."'" : "NULL"); + $sql .= ", ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL"); + $sql .= ", ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL"); + $sql .= ", ".($this->medium_color ? "'".$this->db->escape($this->medium_color)."'" : "NULL"); + $sql .= ", ".($this->route_description ? "'".$this->db->escape($this->route_description)."'" : "NULL"); + $sql .= ", ".$installDateSQL; + $sql .= ", ".(int)($this->status ?: 1); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".(int)$user->id; + $sql .= ")"; + + $resql = $this->db->query($sql); + if ($resql) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } else { + $error++; + $this->error = $this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Fetch connection + * + * @param int $id ID + * @return int >0 if OK, <0 if KO + */ + public function fetch($id) + { + $sql = "SELECT c.*,"; + $sql .= " src.label as source_label, src.ref as source_ref,"; + $sql .= " tgt.label as target_label, tgt.ref as target_ref,"; + $sql .= " mt.label as medium_type_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as src ON c.fk_source = src.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as tgt ON c.fk_target = tgt.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_medium_type as mt ON c.fk_medium_type = mt.rowid"; + $sql .= " WHERE c.rowid = ".(int)$id; + + $resql = $this->db->query($sql); + if ($resql) { + if ($obj = $this->db->fetch_object($resql)) { + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_source = $obj->fk_source; + $this->fk_target = $obj->fk_target; + $this->label = $obj->label; + $this->fk_medium_type = $obj->fk_medium_type; + $this->medium_type_text = $obj->medium_type_text; + $this->medium_spec = $obj->medium_spec; + $this->medium_length = $obj->medium_length; + $this->medium_color = $obj->medium_color; + $this->route_description = $obj->route_description; + $this->installation_date = $obj->installation_date; + $this->status = $obj->status; + $this->note_private = $obj->note_private; + $this->note_public = $obj->note_public; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->source_label = $obj->source_label; + $this->source_ref = $obj->source_ref; + $this->target_label = $obj->target_label; + $this->target_ref = $obj->target_ref; + $this->medium_type_label = $obj->medium_type_label; + + $this->db->free($resql); + return 1; + } + $this->db->free($resql); + return 0; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update connection + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + // installation_date als DATE-Feld (YYYY-MM-DD String) sicher escapen + $installDateSQL = "NULL"; + if ($this->installation_date) { + $installDateSQL = "'".$this->db->escape($this->installation_date)."'"; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " fk_source = ".(int)$this->fk_source; + $sql .= ", fk_target = ".(int)$this->fk_target; + $sql .= ", label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", fk_medium_type = ".($this->fk_medium_type > 0 ? (int)$this->fk_medium_type : "NULL"); + $sql .= ", medium_type_text = ".($this->medium_type_text ? "'".$this->db->escape($this->medium_type_text)."'" : "NULL"); + $sql .= ", medium_spec = ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL"); + $sql .= ", medium_length = ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL"); + $sql .= ", medium_color = ".($this->medium_color ? "'".$this->db->escape($this->medium_color)."'" : "NULL"); + $sql .= ", route_description = ".($this->route_description ? "'".$this->db->escape($this->route_description)."'" : "NULL"); + $sql .= ", installation_date = ".$installDateSQL; + $sql .= ", status = ".(int)$this->status; + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); + $sql .= ", fk_user_modif = ".(int)$user->id; + $sql .= " WHERE rowid = ".(int)$this->id; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->error = $this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete connection + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function delete($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".(int)$this->id; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->error = $this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all connections for an Anlage (as source or target) + * + * @param int $anlageId Anlage ID + * @return array Array of AnlageConnection objects + */ + public function fetchByAnlage($anlageId) + { + $result = array(); + + $sql = "SELECT c.*,"; + $sql .= " src.label as source_label, src.ref as source_ref,"; + $sql .= " tgt.label as target_label, tgt.ref as target_ref,"; + $sql .= " mt.label as medium_type_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as src ON c.fk_source = src.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as tgt ON c.fk_target = tgt.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_medium_type as mt ON c.fk_medium_type = mt.rowid"; + $sql .= " WHERE c.fk_source = ".(int)$anlageId." OR c.fk_target = ".(int)$anlageId; + $sql .= " ORDER BY c.rowid"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $conn = new AnlageConnection($this->db); + $conn->id = $obj->rowid; + $conn->entity = $obj->entity; + $conn->fk_source = $obj->fk_source; + $conn->fk_target = $obj->fk_target; + $conn->label = $obj->label; + $conn->fk_medium_type = $obj->fk_medium_type; + $conn->medium_type_text = $obj->medium_type_text; + $conn->medium_spec = $obj->medium_spec; + $conn->medium_length = $obj->medium_length; + $conn->medium_color = $obj->medium_color; + $conn->route_description = $obj->route_description; + $conn->installation_date = $obj->installation_date; + $conn->status = $obj->status; + $conn->source_label = $obj->source_label; + $conn->source_ref = $obj->source_ref; + $conn->target_label = $obj->target_label; + $conn->target_ref = $obj->target_ref; + $conn->medium_type_label = $obj->medium_type_label; + $result[] = $conn; + } + $this->db->free($resql); + } + + return $result; + } + + /** + * Fetch all connections for a customer (across all anlagen) + * + * @param int $socId Societe ID + * @param int $systemId Optional system filter + * @return array Array of AnlageConnection objects + */ + public function fetchBySociete($socId, $systemId = 0) + { + $result = array(); + + $sql = "SELECT c.*,"; + $sql .= " src.label as source_label, src.ref as source_ref,"; + $sql .= " tgt.label as target_label, tgt.ref as target_ref,"; + $sql .= " mt.label as medium_type_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as src ON c.fk_source = src.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as tgt ON c.fk_target = tgt.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_medium_type as mt ON c.fk_medium_type = mt.rowid"; + $sql .= " WHERE src.fk_soc = ".(int)$socId; + if ($systemId > 0) { + $sql .= " AND src.fk_system = ".(int)$systemId; + } + $sql .= " ORDER BY src.label, c.rowid"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $conn = new AnlageConnection($this->db); + $conn->id = $obj->rowid; + $conn->entity = $obj->entity; + $conn->fk_source = $obj->fk_source; + $conn->fk_target = $obj->fk_target; + $conn->label = $obj->label; + $conn->fk_medium_type = $obj->fk_medium_type; + $conn->medium_type_text = $obj->medium_type_text; + $conn->medium_spec = $obj->medium_spec; + $conn->medium_length = $obj->medium_length; + $conn->medium_color = $obj->medium_color; + $conn->route_description = $obj->route_description; + $conn->installation_date = $obj->installation_date; + $conn->status = $obj->status; + $conn->source_label = $obj->source_label; + $conn->source_ref = $obj->source_ref; + $conn->target_label = $obj->target_label; + $conn->target_ref = $obj->target_ref; + $conn->medium_type_label = $obj->medium_type_label; + $result[] = $conn; + } + $this->db->free($resql); + } + + return $result; + } + + /** + * Get display label for connection + * + * @return string Display label + */ + public function getDisplayLabel() + { + $parts = array(); + + // Medium type + $medium = $this->medium_type_label ?: $this->medium_type_text; + if ($medium) { + $mediumInfo = $medium; + if ($this->medium_spec) { + $mediumInfo .= ' '.$this->medium_spec; + } + if ($this->medium_length) { + $mediumInfo .= ' ('.$this->medium_length.')'; + } + $parts[] = $mediumInfo; + } + + // Label + if ($this->label) { + $parts[] = $this->label; + } + + return implode(' - ', $parts); + } +} diff --git a/class/anlagefile.class.php b/class/anlagefile.class.php new file mode 100755 index 0000000..0e9b956 --- /dev/null +++ b/class/anlagefile.class.php @@ -0,0 +1,447 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->fk_anlage) || empty($this->filename) || empty($this->filepath)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Determine file type + if (empty($this->file_type)) { + $this->file_type = $this->determineFileType($this->mimetype, $this->filename); + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_anlage, filename, filepath, filesize, mimetype,"; + $sql .= " file_type, label, description, is_cover, is_pinned, position, share,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $this->fk_anlage); + $sql .= ", '".$this->db->escape($this->filename)."'"; + $sql .= ", '".$this->db->escape($this->filepath)."'"; + $sql .= ", ".((int) $this->filesize); + $sql .= ", ".($this->mimetype ? "'".$this->db->escape($this->mimetype)."'" : "NULL"); + $sql .= ", '".$this->db->escape($this->file_type)."'"; + $sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".((int) $this->is_cover); + $sql .= ", ".((int) $this->is_pinned); + $sql .= ", ".((int) $this->position); + $sql .= ", ".($this->share ? "'".$this->db->escape($this->share)."'" : "NULL"); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + global $conf; + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".((int) $id); + $sql .= " AND entity = ".((int) $conf->entity); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_anlage = $obj->fk_anlage; + $this->filename = $obj->filename; + $this->filepath = $obj->filepath; + $this->filesize = $obj->filesize; + $this->mimetype = $obj->mimetype; + $this->file_type = $obj->file_type; + $this->label = $obj->label; + $this->description = $obj->description; + $this->is_cover = $obj->is_cover; + $this->is_pinned = $obj->is_pinned; + $this->position = $obj->position; + $this->share = $obj->share; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Delete object in database and file from disk + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + global $conf; + + $error = 0; + + // Delete physical file (use clean filepath) + $fullpath = $this->getFullPath(); + if (file_exists($fullpath)) { + dol_delete_file($fullpath); + + // Delete thumbnail if exists + $thumbpath = dirname($fullpath).'/thumbs/'.pathinfo($this->filename, PATHINFO_FILENAME).'_small.'.pathinfo($this->filename, PATHINFO_EXTENSION); + if (file_exists($thumbpath)) { + dol_delete_file($thumbpath); + } + } + + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all files for an installation element + * + * @param int $anlageId Installation element ID + * @param string $fileType Filter by file type (image, pdf, document, other) + * @return array Array of AnlageFile objects + */ + public function fetchAllByAnlage($anlageId, $fileType = '') + { + global $conf; + + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $anlageId); + $sql .= " AND entity = ".((int) $conf->entity); + if ($fileType) { + $sql .= " AND file_type = '".$this->db->escape($fileType)."'"; + } + $sql .= " ORDER BY is_pinned DESC, is_cover DESC, position ASC, filename ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $file = new AnlageFile($this->db); + $file->id = $obj->rowid; + $file->fk_anlage = $obj->fk_anlage; + $file->filename = $obj->filename; + $file->filepath = $obj->filepath; + $file->filesize = $obj->filesize; + $file->mimetype = $obj->mimetype; + $file->file_type = $obj->file_type; + $file->label = $obj->label; + $file->description = $obj->description; + $file->is_cover = $obj->is_cover; + $file->is_pinned = $obj->is_pinned; + $file->position = $obj->position; + $file->share = $obj->share; + $file->date_creation = $this->db->jdate($obj->date_creation); + + $results[] = $file; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Determine file type from mimetype and filename + * + * @param string $mimetype MIME type + * @param string $filename Filename + * @return string File type (image, pdf, document, other) + */ + public function determineFileType($mimetype, $filename) + { + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if (in_array($ext, array('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp')) || strpos($mimetype, 'image/') === 0) { + return 'image'; + } + if ($ext == 'pdf' || $mimetype == 'application/pdf') { + return 'pdf'; + } + if (in_array($ext, array('doc', 'docx', 'xls', 'xlsx', 'odt', 'ods', 'txt', 'rtf'))) { + return 'document'; + } + if (in_array($ext, array('zip', 'rar', '7z', 'tar', 'gz', 'tgz'))) { + return 'archive'; + } + + return 'other'; + } + + /** + * Get full file path + * + * @return string + */ + public function getFullPath() + { + global $conf; + return $conf->kundenkarte->dir_output.'/'.$this->getCleanFilepath(); + } + + /** + * Get URL to view/download the file + * + * @param bool $forceDownload If true, force download instead of inline display + * @return string + */ + public function getUrl($forceDownload = false) + { + // Sanitize filepath - ensure it's relative, not absolute + $filepath = $this->getCleanFilepath(); + $url = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file='.urlencode($filepath); + + // Add attachment=0 for inline display (especially for PDFs) + // If forceDownload is true, don't add this parameter (default Dolibarr behavior is download) + if (!$forceDownload) { + $url .= '&attachment=0'; + } + + return $url; + } + + /** + * Get clean relative filepath (fixes bad data) + * + * @return string Clean relative filepath + */ + public function getCleanFilepath() + { + $filepath = $this->filepath; + + // If filepath contains "anlagen/" extract from there + if (strpos($filepath, 'anlagen/') !== false) { + $filepath = substr($filepath, strpos($filepath, 'anlagen/')); + } + // If it's still an absolute path (starts with / or file:// or Windows drive), just use filename + elseif (preg_match('/^(\/|file:\/\/|[A-Za-z]:)/', $filepath)) { + // Try to get just the filename and rebuild proper path + $filename = basename($filepath); + // Use fk_anlage to build correct path if available + if ($this->fk_anlage > 0) { + global $db; + $sql = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE rowid = ".((int) $this->fk_anlage); + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql)) { + $obj = $db->fetch_object($resql); + $filepath = 'anlagen/'.$obj->fk_soc.'/'.$this->fk_anlage.'/'.$filename; + } + } + } + + return $filepath; + } + + /** + * Get thumbnail URL for images + * + * @return string + */ + public function getThumbUrl() + { + if ($this->file_type != 'image') { + return ''; + } + + $filepath = $this->getCleanFilepath(); + $dir = dirname($filepath); + $filename = pathinfo($this->filename, PATHINFO_FILENAME); + $ext = pathinfo($this->filename, PATHINFO_EXTENSION); + + $thumbpath = $dir.'/thumbs/'.$filename.'_small.'.$ext; + + // Use attachment=0 for inline display + return DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file='.urlencode($thumbpath).'&attachment=0'; + } + + /** + * Set this file as cover image + * + * @param User $user User making the change + * @return int <0 if KO, >0 if OK + */ + public function setAsCover($user) + { + $this->db->begin(); + + // Unset other covers for this anlage + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET is_cover = 0"; + $sql .= " WHERE fk_anlage = ".((int) $this->fk_anlage); + $this->db->query($sql); + + // Set this as cover + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET is_cover = 1, fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + + if ($resql) { + $this->is_cover = 1; + $this->db->commit(); + return 1; + } else { + $this->db->rollback(); + return -1; + } + } + + /** + * Toggle pinned status of file + * + * @param User $user User making the change + * @return int <0 if KO, >0 if OK + */ + public function togglePin($user) + { + $newStatus = $this->is_pinned ? 0 : 1; + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " SET is_pinned = ".((int) $newStatus); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if ($resql) { + $this->is_pinned = $newStatus; + return 1; + } + return -1; + } + + /** + * Generate thumbnail for image + * + * @return bool + */ + public function generateThumbnail() + { + global $conf; + + if ($this->file_type != 'image') { + return false; + } + + require_once DOL_DOCUMENT_ROOT.'/core/lib/images.lib.php'; + + $fullpath = $this->getFullPath(); + if (!file_exists($fullpath)) { + return false; + } + + $thumbdir = dirname($fullpath).'/thumbs'; + if (!is_dir($thumbdir)) { + dol_mkdir($thumbdir); + } + + // Generate small thumbnail + $result = vignette($fullpath, 200, 160, '_small', 80, 'thumbs'); + + return ($result !== false); + } +} diff --git a/class/anlagetype.class.php b/class/anlagetype.class.php new file mode 100755 index 0000000..07f743e --- /dev/null +++ b/class/anlagetype.class.php @@ -0,0 +1,386 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->ref) || empty($this->label) || empty($this->fk_system)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, label_short, description, fk_system,"; + $sql .= " can_have_children, can_be_nested, allowed_parent_types, can_have_equipment,"; + $sql .= " picto, color, is_system, position, active,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= "0"; // entity 0 = global + $sql .= ", '".$this->db->escape($this->ref)."'"; + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".((int) $this->fk_system); + $sql .= ", ".((int) $this->can_have_children); + $sql .= ", ".((int) $this->can_be_nested); + $sql .= ", ".($this->allowed_parent_types ? "'".$this->db->escape($this->allowed_parent_types)."'" : "NULL"); + $sql .= ", ".((int) $this->can_have_equipment); + $sql .= ", ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", 0"; // is_system = 0 for user-created + $sql .= ", ".((int) $this->position); + $sql .= ", ".((int) ($this->active !== null ? $this->active : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE t.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->label_short = $obj->label_short; + $this->description = $obj->description; + $this->fk_system = $obj->fk_system; + $this->can_have_children = $obj->can_have_children; + $this->can_be_nested = $obj->can_be_nested; + $this->allowed_parent_types = $obj->allowed_parent_types; + $this->can_have_equipment = $obj->can_have_equipment ?? 0; + $this->picto = $obj->picto; + $this->color = $obj->color; + $this->is_system = $obj->is_system; + $this->position = $obj->position; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->system_label = $obj->system_label; + $this->system_code = $obj->system_code; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = '".$this->db->escape($this->ref)."'"; + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", fk_system = ".((int) $this->fk_system); + $sql .= ", can_have_children = ".((int) $this->can_have_children); + $sql .= ", can_be_nested = ".((int) $this->can_be_nested); + $sql .= ", allowed_parent_types = ".($this->allowed_parent_types ? "'".$this->db->escape($this->allowed_parent_types)."'" : "NULL"); + $sql .= ", can_have_equipment = ".((int) $this->can_have_equipment); + $sql .= ", picto = ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", position = ".((int) $this->position); + $sql .= ", active = ".((int) $this->active); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + global $conf; + + // Check if type is in use + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage"; + $sql .= " WHERE fk_anlage_type = ".((int) $this->id); + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + if ($obj->cnt > 0) { + $this->error = 'ErrorTypeInUse'; + return -1; + } + } + + // Cannot delete system types + if ($this->is_system) { + $this->error = 'ErrorCannotDeleteSystemType'; + return -2; + } + + $error = 0; + $this->db->begin(); + + // Delete fields first + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field WHERE fk_anlage_type = ".((int) $this->id); + $this->db->query($sql); + + // Delete type + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all types for a system + * + * @param int $systemId System ID (0 = all) + * @param int $activeOnly Only active types + * @param int $excludeGlobal 1 = GLOBAL-Typen ausschliessen (fuer Admin-Ansicht) + * @return array Array of AnlageType objects + */ + public function fetchAllBySystem($systemId = 0, $activeOnly = 1, $excludeGlobal = 0) + { + $results = array(); + + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE 1 = 1"; + if ($excludeGlobal) { + // GLOBAL-Typen (Gebaeude) ausschliessen (Admin-Ansicht) + $sql .= " AND (s.code IS NULL OR s.code != 'GLOBAL')"; + } + if ($systemId > 0) { + if (!$excludeGlobal) { + // Typen dieses Systems UND GLOBAL-Typen (fuer Tabs-Ansicht) + $sql .= " AND (t.fk_system = ".((int) $systemId)." OR s.code = 'GLOBAL')"; + } else { + // Nur Typen dieses Systems (fuer Admin-Ansicht) + $sql .= " AND t.fk_system = ".((int) $systemId); + } + } + if ($activeOnly) { + $sql .= " AND t.active = 1"; + } + // Sort: GLOBAL types first (position 0), then by position, then by label + $sql .= " ORDER BY s.position ASC, t.position ASC, t.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $type = new AnlageType($this->db); + $type->id = $obj->rowid; + $type->ref = $obj->ref; + $type->label = $obj->label; + $type->label_short = $obj->label_short; + $type->fk_system = $obj->fk_system; + $type->can_have_children = $obj->can_have_children; + $type->can_be_nested = $obj->can_be_nested; + $type->allowed_parent_types = $obj->allowed_parent_types; + $type->can_have_equipment = $obj->can_have_equipment ?? 0; + $type->picto = $obj->picto; + $type->is_system = $obj->is_system; + $type->position = $obj->position; + $type->active = $obj->active; + $type->system_label = $obj->system_label; + $type->system_code = $obj->system_code; + + $results[] = $type; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch fields for this type + * + * @param int $activeOnly Only active fields + * @return array Array of field objects + */ + public function fetchFields($activeOnly = 1) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + $sql .= " WHERE fk_anlage_type = ".((int) $this->id); + if ($activeOnly) { + $sql .= " AND active = 1"; + } + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $results[] = $obj; + } + $this->db->free($resql); + } + + $this->fields = $results; + return $results; + } + + /** + * Get allowed parent types as array + * + * @return array + */ + public function getAllowedParentTypesArray() + { + if (empty($this->allowed_parent_types)) { + return array(); + } + return array_map('trim', explode(',', $this->allowed_parent_types)); + } + + /** + * Check if this type can be child of another type + * + * @param string $parentTypeRef Parent type reference + * @return bool + */ + public function canBeChildOf($parentTypeRef) + { + if (empty($this->allowed_parent_types)) { + return true; // No restriction = can be anywhere + } + $allowed = $this->getAllowedParentTypesArray(); + return in_array($parentTypeRef, $allowed); + } +} diff --git a/class/auditlog.class.php b/class/auditlog.class.php new file mode 100755 index 0000000..fd049ab --- /dev/null +++ b/class/auditlog.class.php @@ -0,0 +1,455 @@ +db = $db; + } + + /** + * Log an action + * + * @param User $user User performing the action + * @param string $objectType Type of object (equipment, carrier, panel, etc.) + * @param int $objectId ID of the object + * @param string $action Action performed (create, update, delete, etc.) + * @param string $objectRef Reference/label of the object (optional) + * @param string $fieldChanged Specific field changed (optional) + * @param mixed $oldValue Previous value (optional) + * @param mixed $newValue New value (optional) + * @param int $socid Customer ID (optional) + * @param int $anlageId Anlage ID (optional) + * @param string $note Additional note (optional) + * @return int Log entry ID or <0 on error + */ + public function log($user, $objectType, $objectId, $action, $objectRef = '', $fieldChanged = '', $oldValue = null, $newValue = null, $socid = 0, $anlageId = 0, $note = '') + { + global $conf; + + $now = dol_now(); + + // Serialize complex values + if (is_array($oldValue) || is_object($oldValue)) { + $oldValue = json_encode($oldValue); + } + if (is_array($newValue) || is_object($newValue)) { + $newValue = json_encode($newValue); + } + + // Get IP address + $ipAddress = ''; + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ipAddress = $_SERVER['HTTP_X_FORWARDED_FOR']; + } elseif (!empty($_SERVER['REMOTE_ADDR'])) { + $ipAddress = $_SERVER['REMOTE_ADDR']; + } + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, object_type, object_id, object_ref, fk_societe, fk_anlage,"; + $sql .= " action, field_changed, old_value, new_value,"; + $sql .= " fk_user, user_login, date_action, note, ip_address"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", '".$this->db->escape($objectType)."'"; + $sql .= ", ".((int) $objectId); + $sql .= ", ".($objectRef ? "'".$this->db->escape($objectRef)."'" : "NULL"); + $sql .= ", ".($socid > 0 ? ((int) $socid) : "NULL"); + $sql .= ", ".($anlageId > 0 ? ((int) $anlageId) : "NULL"); + $sql .= ", '".$this->db->escape($action)."'"; + $sql .= ", ".($fieldChanged ? "'".$this->db->escape($fieldChanged)."'" : "NULL"); + $sql .= ", ".($oldValue !== null ? "'".$this->db->escape($oldValue)."'" : "NULL"); + $sql .= ", ".($newValue !== null ? "'".$this->db->escape($newValue)."'" : "NULL"); + $sql .= ", ".((int) $user->id); + $sql .= ", '".$this->db->escape($user->login)."'"; + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".($note ? "'".$this->db->escape($note)."'" : "NULL"); + $sql .= ", ".($ipAddress ? "'".$this->db->escape($ipAddress)."'" : "NULL"); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + return $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + /** + * Log object creation + */ + public function logCreate($user, $objectType, $objectId, $objectRef = '', $socid = 0, $anlageId = 0, $data = null) + { + return $this->log($user, $objectType, $objectId, self::ACTION_CREATE, $objectRef, '', null, $data, $socid, $anlageId); + } + + /** + * Log object update + */ + public function logUpdate($user, $objectType, $objectId, $objectRef = '', $fieldChanged = '', $oldValue = null, $newValue = null, $socid = 0, $anlageId = 0) + { + return $this->log($user, $objectType, $objectId, self::ACTION_UPDATE, $objectRef, $fieldChanged, $oldValue, $newValue, $socid, $anlageId); + } + + /** + * Log object deletion + */ + public function logDelete($user, $objectType, $objectId, $objectRef = '', $socid = 0, $anlageId = 0, $data = null) + { + return $this->log($user, $objectType, $objectId, self::ACTION_DELETE, $objectRef, '', $data, null, $socid, $anlageId); + } + + /** + * Log object move (position change) + */ + public function logMove($user, $objectType, $objectId, $objectRef = '', $oldPosition = null, $newPosition = null, $socid = 0, $anlageId = 0) + { + return $this->log($user, $objectType, $objectId, self::ACTION_MOVE, $objectRef, 'position', $oldPosition, $newPosition, $socid, $anlageId); + } + + /** + * Log object duplication + */ + public function logDuplicate($user, $objectType, $objectId, $objectRef = '', $sourceId = 0, $socid = 0, $anlageId = 0) + { + return $this->log($user, $objectType, $objectId, self::ACTION_DUPLICATE, $objectRef, '', $sourceId, $objectId, $socid, $anlageId, 'Kopiert von ID '.$sourceId); + } + + /** + * Fetch audit log entries for an object + * + * @param string $objectType Object type + * @param int $objectId Object ID + * @param int $limit Max entries (0 = no limit) + * @return array Array of AuditLog objects + */ + public function fetchByObject($objectType, $objectId, $limit = 50) + { + $results = array(); + + $sql = "SELECT a.*, u.firstname, u.lastname, s.nom as societe_name"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON a.fk_user = u.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON a.fk_societe = s.rowid"; + $sql .= " WHERE a.object_type = '".$this->db->escape($objectType)."'"; + $sql .= " AND a.object_id = ".((int) $objectId); + $sql .= " ORDER BY a.date_action DESC"; + if ($limit > 0) { + $sql .= " LIMIT ".((int) $limit); + } + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $log = new AuditLog($this->db); + $log->id = $obj->rowid; + $log->object_type = $obj->object_type; + $log->object_id = $obj->object_id; + $log->object_ref = $obj->object_ref; + $log->fk_societe = $obj->fk_societe; + $log->fk_anlage = $obj->fk_anlage; + $log->action = $obj->action; + $log->field_changed = $obj->field_changed; + $log->old_value = $obj->old_value; + $log->new_value = $obj->new_value; + $log->fk_user = $obj->fk_user; + $log->user_login = $obj->user_login; + $log->date_action = $this->db->jdate($obj->date_action); + $log->note = $obj->note; + $log->ip_address = $obj->ip_address; + $log->user_name = trim($obj->firstname.' '.$obj->lastname); + $log->societe_name = $obj->societe_name; + + $results[] = $log; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch audit log entries for an Anlage (installation) + * + * @param int $anlageId Anlage ID + * @param int $limit Max entries + * @return array Array of AuditLog objects + */ + public function fetchByAnlage($anlageId, $limit = 100) + { + $results = array(); + + $sql = "SELECT a.*, u.firstname, u.lastname"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON a.fk_user = u.rowid"; + $sql .= " WHERE a.fk_anlage = ".((int) $anlageId); + $sql .= " ORDER BY a.date_action DESC"; + if ($limit > 0) { + $sql .= " LIMIT ".((int) $limit); + } + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $log = new AuditLog($this->db); + $log->id = $obj->rowid; + $log->object_type = $obj->object_type; + $log->object_id = $obj->object_id; + $log->object_ref = $obj->object_ref; + $log->fk_societe = $obj->fk_societe; + $log->fk_anlage = $obj->fk_anlage; + $log->action = $obj->action; + $log->field_changed = $obj->field_changed; + $log->old_value = $obj->old_value; + $log->new_value = $obj->new_value; + $log->fk_user = $obj->fk_user; + $log->user_login = $obj->user_login; + $log->date_action = $this->db->jdate($obj->date_action); + $log->note = $obj->note; + $log->ip_address = $obj->ip_address; + $log->user_name = trim($obj->firstname.' '.$obj->lastname); + + $results[] = $log; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch audit log entries for a customer + * + * @param int $socid Societe ID + * @param int $limit Max entries + * @return array Array of AuditLog objects + */ + public function fetchBySociete($socid, $limit = 100) + { + $results = array(); + + $sql = "SELECT a.*, u.firstname, u.lastname"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as a"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON a.fk_user = u.rowid"; + $sql .= " WHERE a.fk_societe = ".((int) $socid); + $sql .= " ORDER BY a.date_action DESC"; + if ($limit > 0) { + $sql .= " LIMIT ".((int) $limit); + } + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $log = new AuditLog($this->db); + $log->id = $obj->rowid; + $log->object_type = $obj->object_type; + $log->object_id = $obj->object_id; + $log->object_ref = $obj->object_ref; + $log->fk_societe = $obj->fk_societe; + $log->fk_anlage = $obj->fk_anlage; + $log->action = $obj->action; + $log->field_changed = $obj->field_changed; + $log->old_value = $obj->old_value; + $log->new_value = $obj->new_value; + $log->fk_user = $obj->fk_user; + $log->user_login = $obj->user_login; + $log->date_action = $this->db->jdate($obj->date_action); + $log->note = $obj->note; + $log->ip_address = $obj->ip_address; + $log->user_name = trim($obj->firstname.' '.$obj->lastname); + + $results[] = $log; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Get human-readable action label + * + * @return string Translated action label + */ + public function getActionLabel() + { + global $langs; + + switch ($this->action) { + case self::ACTION_CREATE: + return $langs->trans('AuditActionCreate'); + case self::ACTION_UPDATE: + return $langs->trans('AuditActionUpdate'); + case self::ACTION_DELETE: + return $langs->trans('AuditActionDelete'); + case self::ACTION_MOVE: + return $langs->trans('AuditActionMove'); + case self::ACTION_DUPLICATE: + return $langs->trans('AuditActionDuplicate'); + case self::ACTION_STATUS_CHANGE: + return $langs->trans('AuditActionStatus'); + default: + return $this->action; + } + } + + /** + * Get human-readable object type label + * + * @return string Translated object type label + */ + public function getObjectTypeLabel() + { + global $langs; + + switch ($this->object_type) { + case self::TYPE_EQUIPMENT: + return $langs->trans('Equipment'); + case self::TYPE_CARRIER: + return $langs->trans('CarrierLabel'); + case self::TYPE_PANEL: + return $langs->trans('PanelLabel'); + case self::TYPE_ANLAGE: + return $langs->trans('Installation'); + case self::TYPE_CONNECTION: + return $langs->trans('Connection'); + case self::TYPE_BUSBAR: + return $langs->trans('Busbar'); + case self::TYPE_EQUIPMENT_TYPE: + return $langs->trans('EquipmentType'); + case self::TYPE_BUSBAR_TYPE: + return $langs->trans('BusbarType'); + default: + return $this->object_type; + } + } + + /** + * Get action icon + * + * @return string FontAwesome icon class + */ + public function getActionIcon() + { + switch ($this->action) { + case self::ACTION_CREATE: + return 'fa-plus-circle'; + case self::ACTION_UPDATE: + return 'fa-edit'; + case self::ACTION_DELETE: + return 'fa-trash'; + case self::ACTION_MOVE: + return 'fa-arrows'; + case self::ACTION_DUPLICATE: + return 'fa-copy'; + case self::ACTION_STATUS_CHANGE: + return 'fa-toggle-on'; + default: + return 'fa-question'; + } + } + + /** + * Get action color + * + * @return string CSS color + */ + public function getActionColor() + { + switch ($this->action) { + case self::ACTION_CREATE: + return '#27ae60'; + case self::ACTION_UPDATE: + return '#3498db'; + case self::ACTION_DELETE: + return '#e74c3c'; + case self::ACTION_MOVE: + return '#9b59b6'; + case self::ACTION_DUPLICATE: + return '#f39c12'; + case self::ACTION_STATUS_CHANGE: + return '#1abc9c'; + default: + return '#95a5a6'; + } + } + + /** + * Clean old audit log entries + * + * @param int $daysToKeep Number of days to keep (default: 365) + * @return int Number of deleted entries or -1 on error + */ + public function cleanOldEntries($daysToKeep = 365) + { + $cutoffDate = dol_now() - ($daysToKeep * 24 * 60 * 60); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE date_action < '".$this->db->idate($cutoffDate)."'"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + return $this->db->affected_rows($resql); + } +} diff --git a/class/buildingtype.class.php b/class/buildingtype.class.php new file mode 100755 index 0000000..ce60fda --- /dev/null +++ b/class/buildingtype.class.php @@ -0,0 +1,363 @@ +db = $db; + } + + /** + * Create building type + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function create($user) + { + global $conf; + + $now = dol_now(); + $this->ref = trim($this->ref); + $this->label = trim($this->label); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, label_short, description, fk_parent, level_type,"; + $sql .= "icon, color, picto, is_system, can_have_children, position, active,"; + $sql .= "date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= (int)$conf->entity; + $sql .= ", '".$this->db->escape($this->ref)."'"; + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".(int)($this->fk_parent ?: 0); + $sql .= ", ".($this->level_type ? "'".$this->db->escape($this->level_type)."'" : "NULL"); + $sql .= ", ".($this->icon ? "'".$this->db->escape($this->icon)."'" : "NULL"); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", ".(int)($this->is_system ?: 0); + $sql .= ", ".(int)($this->can_have_children !== null ? $this->can_have_children : 1); + $sql .= ", ".(int)($this->position ?: 0); + $sql .= ", ".(int)($this->active !== null ? $this->active : 1); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".(int)$user->id; + $sql .= ")"; + + $resql = $this->db->query($sql); + if ($resql) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + return $this->id; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Fetch building type + * + * @param int $id ID + * @param string $ref Reference + * @return int >0 if OK, <0 if KO + */ + public function fetch($id, $ref = '') + { + $sql = "SELECT t.*, p.label as parent_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX.$this->table_element." as p ON t.fk_parent = p.rowid"; + $sql .= " WHERE "; + if ($id > 0) { + $sql .= "t.rowid = ".(int)$id; + } else { + $sql .= "t.ref = '".$this->db->escape($ref)."'"; + } + + $resql = $this->db->query($sql); + if ($resql) { + if ($obj = $this->db->fetch_object($resql)) { + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->label_short = $obj->label_short; + $this->description = $obj->description; + $this->fk_parent = $obj->fk_parent; + $this->level_type = $obj->level_type; + $this->icon = $obj->icon; + $this->color = $obj->color; + $this->picto = $obj->picto; + $this->is_system = $obj->is_system; + $this->can_have_children = $obj->can_have_children; + $this->position = $obj->position; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + $this->parent_label = $obj->parent_label; + + $this->db->free($resql); + return 1; + } + $this->db->free($resql); + return 0; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update building type + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function update($user) + { + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = '".$this->db->escape($this->ref)."'"; + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", fk_parent = ".(int)($this->fk_parent ?: 0); + $sql .= ", level_type = ".($this->level_type ? "'".$this->db->escape($this->level_type)."'" : "NULL"); + $sql .= ", icon = ".($this->icon ? "'".$this->db->escape($this->icon)."'" : "NULL"); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", can_have_children = ".(int)$this->can_have_children; + $sql .= ", position = ".(int)$this->position; + $sql .= ", active = ".(int)$this->active; + $sql .= ", fk_user_modif = ".(int)$user->id; + $sql .= " WHERE rowid = ".(int)$this->id; + + $resql = $this->db->query($sql); + if ($resql) { + return 1; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Delete building type + * + * @param User $user User object + * @return int >0 if OK, <0 if KO + */ + public function delete($user) + { + // Don't allow deleting system types + if ($this->is_system) { + $this->error = 'CannotDeleteSystemType'; + return -1; + } + + // Check if type is used as parent + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_parent = ".(int)$this->id; + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + if ($obj->cnt > 0) { + $this->error = 'CannotDeleteTypeWithChildren'; + return -2; + } + } + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".(int)$this->id; + + $resql = $this->db->query($sql); + if ($resql) { + return 1; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Fetch all building types + * + * @param int $activeOnly Only active types + * @param string $levelType Filter by level type + * @return array Array of BuildingType objects + */ + public function fetchAll($activeOnly = 1, $levelType = '') + { + global $conf; + + $result = array(); + + $sql = "SELECT t.*, p.label as parent_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX.$this->table_element." as p ON t.fk_parent = p.rowid"; + $sql .= " WHERE (t.entity = ".(int)$conf->entity." OR t.entity = 0)"; + if ($activeOnly) { + $sql .= " AND t.active = 1"; + } + if ($levelType) { + $sql .= " AND t.level_type = '".$this->db->escape($levelType)."'"; + } + $sql .= " ORDER BY t.level_type, t.position, t.label"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $type = new BuildingType($this->db); + $type->id = $obj->rowid; + $type->entity = $obj->entity; + $type->ref = $obj->ref; + $type->label = $obj->label; + $type->label_short = $obj->label_short; + $type->description = $obj->description; + $type->fk_parent = $obj->fk_parent; + $type->level_type = $obj->level_type; + $type->icon = $obj->icon; + $type->color = $obj->color; + $type->picto = $obj->picto; + $type->is_system = $obj->is_system; + $type->can_have_children = $obj->can_have_children; + $type->position = $obj->position; + $type->active = $obj->active; + $type->parent_label = $obj->parent_label; + $result[] = $type; + } + $this->db->free($resql); + } + + return $result; + } + + /** + * Fetch types grouped by level type + * + * @param int $activeOnly Only active types + * @return array Array grouped by level_type + */ + public function fetchGroupedByLevel($activeOnly = 1) + { + $all = $this->fetchAll($activeOnly); + $grouped = array(); + + foreach ($all as $type) { + $level = $type->level_type ?: 'other'; + if (!isset($grouped[$level])) { + $grouped[$level] = array(); + } + $grouped[$level][] = $type; + } + + return $grouped; + } + + /** + * Get level type label + * + * @return string Translated label + */ + public function getLevelTypeLabel() + { + global $langs; + $langs->load('kundenkarte@kundenkarte'); + + $labels = array( + self::LEVEL_BUILDING => $langs->trans('BuildingLevelBuilding'), + self::LEVEL_FLOOR => $langs->trans('BuildingLevelFloor'), + self::LEVEL_WING => $langs->trans('BuildingLevelWing'), + self::LEVEL_CORRIDOR => $langs->trans('BuildingLevelCorridor'), + self::LEVEL_ROOM => $langs->trans('BuildingLevelRoom'), + self::LEVEL_AREA => $langs->trans('BuildingLevelArea'), + ); + + return isset($labels[$this->level_type]) ? $labels[$this->level_type] : $this->level_type; + } + + /** + * Get all level types with labels + * + * @return array Array of level_type => label + */ + public static function getLevelTypes() + { + global $langs; + $langs->load('kundenkarte@kundenkarte'); + + return array( + self::LEVEL_BUILDING => $langs->trans('BuildingLevelBuilding'), + self::LEVEL_FLOOR => $langs->trans('BuildingLevelFloor'), + self::LEVEL_WING => $langs->trans('BuildingLevelWing'), + self::LEVEL_CORRIDOR => $langs->trans('BuildingLevelCorridor'), + self::LEVEL_ROOM => $langs->trans('BuildingLevelRoom'), + self::LEVEL_AREA => $langs->trans('BuildingLevelArea'), + ); + } + + /** + * Get next available position + * + * @param string $levelType Level type + * @return int Next position + */ + public function getNextPosition($levelType = '') + { + $sql = "SELECT MAX(position) as maxpos FROM ".MAIN_DB_PREFIX.$this->table_element; + if ($levelType) { + $sql .= " WHERE level_type = '".$this->db->escape($levelType)."'"; + } + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + return ($obj->maxpos ?: 0) + 10; + } + return 10; + } +} diff --git a/class/busbartype.class.php b/class/busbartype.class.php new file mode 100755 index 0000000..2c769ee --- /dev/null +++ b/class/busbartype.class.php @@ -0,0 +1,391 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->ref) || empty($this->label) || empty($this->phases)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, label_short, description, fk_system,"; + $sql .= " phases, num_lines, color, default_color, line_height, line_spacing, position_default,"; + $sql .= " fk_product, picto, icon_file, is_system, position, active,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= "0"; // entity 0 = global + $sql .= ", '".$this->db->escape($this->ref)."'"; + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".((int) $this->fk_system); + $sql .= ", '".$this->db->escape($this->phases)."'"; + $sql .= ", ".((int) ($this->num_lines > 0 ? $this->num_lines : 1)); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", ".($this->default_color ? "'".$this->db->escape($this->default_color)."'" : "NULL"); + $sql .= ", ".((int) ($this->line_height > 0 ? $this->line_height : 3)); + $sql .= ", ".((int) ($this->line_spacing > 0 ? $this->line_spacing : 4)); + $sql .= ", '".$this->db->escape($this->position_default ?: 'below')."'"; + $sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", ".($this->icon_file ? "'".$this->db->escape($this->icon_file)."'" : "NULL"); + $sql .= ", 0"; // is_system = 0 for user-created + $sql .= ", ".((int) $this->position); + $sql .= ", ".((int) ($this->active !== null ? $this->active : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT t.*, s.label as system_label, s.code as system_code,"; + $sql .= " p.ref as product_ref, p.label as product_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON t.fk_product = p.rowid"; + $sql .= " WHERE t.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->label_short = $obj->label_short; + $this->description = $obj->description; + $this->fk_system = $obj->fk_system; + $this->phases = $obj->phases; + $this->num_lines = $obj->num_lines; + $this->color = $obj->color; + $this->default_color = $obj->default_color; + $this->line_height = $obj->line_height; + $this->line_spacing = $obj->line_spacing; + $this->position_default = $obj->position_default ?: 'below'; + $this->fk_product = $obj->fk_product; + $this->picto = $obj->picto; + $this->icon_file = $obj->icon_file; + $this->is_system = $obj->is_system; + $this->position = $obj->position; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->system_label = $obj->system_label; + $this->system_code = $obj->system_code; + $this->product_ref = $obj->product_ref; + $this->product_label = $obj->product_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = '".$this->db->escape($this->ref)."'"; + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", fk_system = ".((int) $this->fk_system); + $sql .= ", phases = '".$this->db->escape($this->phases)."'"; + $sql .= ", num_lines = ".((int) ($this->num_lines > 0 ? $this->num_lines : 1)); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", default_color = ".($this->default_color ? "'".$this->db->escape($this->default_color)."'" : "NULL"); + $sql .= ", line_height = ".((int) ($this->line_height > 0 ? $this->line_height : 3)); + $sql .= ", line_spacing = ".((int) ($this->line_spacing > 0 ? $this->line_spacing : 4)); + $sql .= ", position_default = '".$this->db->escape($this->position_default ?: 'below')."'"; + $sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", picto = ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", icon_file = ".($this->icon_file ? "'".$this->db->escape($this->icon_file)."'" : "NULL"); + $sql .= ", position = ".((int) $this->position); + $sql .= ", active = ".((int) $this->active); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + global $conf; + + // Check if type is in use (connections referencing this type) + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection"; + $sql .= " WHERE fk_busbar_type = ".((int) $this->id); + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + if ($obj->cnt > 0) { + $this->error = 'ErrorTypeInUse'; + return -1; + } + } + + // Cannot delete system types + if ($this->is_system) { + $this->error = 'ErrorCannotDeleteSystemType'; + return -2; + } + + $error = 0; + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all busbar types for a system + * + * @param int $systemId System ID (0 = all) + * @param int $activeOnly Only active types + * @return array Array of BusbarType objects + */ + public function fetchAllBySystem($systemId = 0, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE 1 = 1"; + if ($systemId > 0) { + // Show types for this system AND global types (fk_system = 0 or NULL) + $sql .= " AND (t.fk_system = ".((int) $systemId)." OR t.fk_system = 0 OR t.fk_system IS NULL)"; + } + if ($activeOnly) { + $sql .= " AND t.active = 1"; + } + $sql .= " ORDER BY t.fk_system ASC, t.position ASC, t.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $type = new BusbarType($this->db); + $type->id = $obj->rowid; + $type->ref = $obj->ref; + $type->label = $obj->label; + $type->label_short = $obj->label_short; + $type->fk_system = $obj->fk_system; + $type->phases = $obj->phases; + $type->num_lines = $obj->num_lines; + $type->color = $obj->color; + $type->default_color = $obj->default_color; + $type->line_height = $obj->line_height; + $type->line_spacing = $obj->line_spacing; + $type->position_default = $obj->position_default; + $type->fk_product = $obj->fk_product; + $type->picto = $obj->picto; + $type->icon_file = $obj->icon_file; + $type->is_system = $obj->is_system; + $type->position = $obj->position; + $type->active = $obj->active; + $type->system_label = $obj->system_label; + $type->system_code = $obj->system_code; + + $results[] = $type; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Get color array from comma-separated string + * + * @return array Array of color codes + */ + public function getColors() + { + if (empty($this->color)) { + return array($this->default_color ?: '#e74c3c'); + } + return explode(',', $this->color); + } + + /** + * Get phase labels array from phases string + * + * @return array Array of phase labels + */ + public function getPhaseLabels() + { + $phases = $this->phases; + + // Parse common phase configurations + switch (strtoupper($phases)) { + case 'L1': + return array('L1'); + case 'L2': + return array('L2'); + case 'L3': + return array('L3'); + case 'N': + return array('N'); + case 'PE': + return array('PE'); + case 'L1N': + return array('L1', 'N'); + case '3P': + return array('L1', 'L2', 'L3'); + case '3P+N': + case '3PN': + return array('L1', 'L2', 'L3', 'N'); + case '3P+N+PE': + case '3PNPE': + return array('L1', 'L2', 'L3', 'N', 'PE'); + default: + // Try to split by comma or + + return preg_split('/[,+]/', $phases); + } + } +} diff --git a/class/equipment.class.php b/class/equipment.class.php new file mode 100755 index 0000000..7427c8d --- /dev/null +++ b/class/equipment.class.php @@ -0,0 +1,514 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->fk_carrier) || empty($this->fk_equipment_type)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Get default width from type if not set + if (empty($this->width_te)) { + $type = new EquipmentType($this->db); + if ($type->fetch($this->fk_equipment_type) > 0) { + $this->width_te = $type->width_te; + } else { + $this->width_te = 1; + } + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_carrier, fk_equipment_type, label,"; + $sql .= " position_te, width_te, field_values, fk_product,"; + $sql .= " fk_protection, protection_label,"; + $sql .= " note_private, status,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $this->fk_carrier); + $sql .= ", ".((int) $this->fk_equipment_type); + $sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", ".((int) $this->position_te); + $sql .= ", ".((int) $this->width_te); + $sql .= ", ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL"); + $sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", ".($this->fk_protection > 0 ? ((int) $this->fk_protection) : "NULL"); + $sql .= ", ".($this->protection_label ? "'".$this->db->escape($this->protection_label)."'" : "NULL"); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".((int) ($this->status !== null ? $this->status : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT e.*, t.label as type_label, t.label_short as type_label_short,"; + $sql .= " t.ref as type_ref, t.color as type_color, t.picto as type_picto, t.icon_file as type_icon_file,"; + $sql .= " t.block_image as type_block_image,"; + $sql .= " t.flow_direction as type_flow_direction, t.terminal_position as type_terminal_position,"; + $sql .= " t.terminals_config as terminals_config,"; + $sql .= " p.ref as product_ref, p.label as product_label,"; + $sql .= " prot.label as protection_device_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as e"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_type as t ON e.fk_equipment_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON e.fk_product = p.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as prot ON e.fk_protection = prot.rowid"; + $sql .= " WHERE e.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_carrier = $obj->fk_carrier; + $this->fk_equipment_type = $obj->fk_equipment_type; + $this->label = $obj->label; + $this->position_te = $obj->position_te; + $this->width_te = $obj->width_te; + $this->field_values = $obj->field_values; + $this->fk_product = $obj->fk_product; + $this->fk_protection = $obj->fk_protection; + $this->protection_label = $obj->protection_label; + $this->note_private = $obj->note_private; + $this->status = $obj->status; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->type_label = $obj->type_label; + $this->type_label_short = $obj->type_label_short; + $this->type_ref = $obj->type_ref; + $this->type_color = $obj->type_color; + $this->type_picto = $obj->type_picto; + $this->type_icon_file = $obj->type_icon_file; + $this->type_block_image = $obj->type_block_image; + $this->type_flow_direction = $obj->type_flow_direction; + $this->type_terminal_position = $obj->type_terminal_position ?: 'both'; + $this->terminals_config = $obj->terminals_config; + $this->product_ref = $obj->product_ref; + $this->product_label = $obj->product_label; + $this->protection_device_label = $obj->protection_device_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " fk_carrier = ".((int) $this->fk_carrier); + $sql .= ", fk_equipment_type = ".((int) $this->fk_equipment_type); + $sql .= ", label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", position_te = ".((int) $this->position_te); + $sql .= ", width_te = ".((int) $this->width_te); + $sql .= ", field_values = ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL"); + $sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", fk_protection = ".($this->fk_protection > 0 ? ((int) $this->fk_protection) : "NULL"); + $sql .= ", protection_label = ".($this->protection_label ? "'".$this->db->escape($this->protection_label)."'" : "NULL"); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + $error = 0; + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all equipment on a carrier + * + * @param int $carrierId Carrier ID + * @param int $activeOnly Only active equipment + * @return array Array of Equipment objects + */ + public function fetchByCarrier($carrierId, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT e.*, t.label as type_label, t.label_short as type_label_short,"; + $sql .= " t.ref as type_ref, t.color as type_color, t.picto as type_picto, t.icon_file as type_icon_file,"; + $sql .= " t.block_image as type_block_image,"; + $sql .= " t.flow_direction as type_flow_direction, t.terminal_position as type_terminal_position,"; + $sql .= " t.terminals_config as terminals_config,"; + $sql .= " prot.label as protection_device_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as e"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_type as t ON e.fk_equipment_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as prot ON e.fk_protection = prot.rowid"; + $sql .= " WHERE e.fk_carrier = ".((int) $carrierId); + if ($activeOnly) { + $sql .= " AND e.status = 1"; + } + $sql .= " ORDER BY e.position_te ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $eq = new Equipment($this->db); + $eq->id = $obj->rowid; + $eq->entity = $obj->entity; + $eq->fk_carrier = $obj->fk_carrier; + $eq->fk_equipment_type = $obj->fk_equipment_type; + $eq->label = $obj->label; + $eq->position_te = $obj->position_te; + $eq->width_te = $obj->width_te; + $eq->field_values = $obj->field_values; + $eq->fk_product = $obj->fk_product; + $eq->fk_protection = $obj->fk_protection; + $eq->protection_label = $obj->protection_label; + $eq->note_private = $obj->note_private; + $eq->status = $obj->status; + + $eq->type_label = $obj->type_label; + $eq->type_label_short = $obj->type_label_short; + $eq->type_ref = $obj->type_ref; + $eq->type_color = $obj->type_color; + $eq->type_picto = $obj->type_picto; + $eq->type_icon_file = $obj->type_icon_file; + $eq->type_block_image = $obj->type_block_image; + $eq->type_flow_direction = $obj->type_flow_direction; + $eq->type_terminal_position = $obj->type_terminal_position ?: 'both'; + $eq->terminals_config = $obj->terminals_config; + $eq->protection_device_label = $obj->protection_device_label; + + $results[] = $eq; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch protection devices (FI/RCD) for an Anlage + * Protection devices are equipment types that can protect other equipment + * + * @param int $anlageId Anlage ID + * @return array Array of Equipment objects that are protection devices + */ + public function fetchProtectionDevices($anlageId) + { + $results = array(); + + // Get all equipment for this anlage that have type with is_protection = 1 + // Or equipment types that are typically protection devices (FI, RCD, etc.) + $sql = "SELECT e.*, t.label as type_label, t.label_short as type_label_short,"; + $sql .= " t.color as type_color, t.ref as type_ref, c.fk_anlage"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as e"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_type as t ON e.fk_equipment_type = t.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_carrier as c ON e.fk_carrier = c.rowid"; + $sql .= " WHERE c.fk_anlage = ".((int) $anlageId); + $sql .= " AND e.status = 1"; + // Filter for protection device types (FI, RCD, FI_LS, etc.) + $sql .= " AND (t.ref LIKE '%FI%' OR t.ref LIKE '%RCD%' OR t.ref LIKE 'RCBO%')"; + $sql .= " ORDER BY c.position ASC, e.position_te ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $eq = new Equipment($this->db); + $eq->id = $obj->rowid; + $eq->fk_carrier = $obj->fk_carrier; + $eq->fk_equipment_type = $obj->fk_equipment_type; + $eq->label = $obj->label; + $eq->position_te = $obj->position_te; + $eq->width_te = $obj->width_te; + $eq->type_label = $obj->type_label; + $eq->type_label_short = $obj->type_label_short; + $eq->type_color = $obj->type_color; + + $results[] = $eq; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Duplicate this equipment (for "+" button) + * + * @param User $user User that creates + * @param int $carrierId Target carrier (0 = same carrier) + * @param int $position Target position (-1 = auto) + * @return int Return integer <0 if KO, Id of new object if OK + */ + public function duplicate($user, $carrierId = 0, $position = -1) + { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; + + $newEquipment = new Equipment($this->db); + $newEquipment->fk_carrier = $carrierId > 0 ? $carrierId : $this->fk_carrier; + $newEquipment->fk_equipment_type = $this->fk_equipment_type; + $newEquipment->label = $this->label; + $newEquipment->width_te = $this->width_te; + $newEquipment->field_values = $this->field_values; + $newEquipment->fk_product = $this->fk_product; + $newEquipment->note_private = $this->note_private; + $newEquipment->status = 1; + + // Find position + if ($position >= 0) { + $newEquipment->position_te = $position; + } else { + // Auto-find next position after this equipment + $carrier = new EquipmentCarrier($this->db); + $carrier->fetch($newEquipment->fk_carrier); + $carrier->fetchEquipment(); + + // Try position right after this element + $tryPos = $this->position_te + $this->width_te; + if ($carrier->isPositionAvailable($tryPos, $this->width_te)) { + $newEquipment->position_te = $tryPos; + } else { + // Find any free position + $newEquipment->position_te = $carrier->getNextFreePosition($this->width_te); + if ($newEquipment->position_te < 0) { + $this->error = 'ErrorNoSpaceOnCarrier'; + return -1; + } + } + } + + return $newEquipment->create($user); + } + + /** + * Get field values as array + * + * @return array + */ + public function getFieldValues() + { + if (empty($this->field_values)) { + return array(); + } + $values = json_decode($this->field_values, true); + return is_array($values) ? $values : array(); + } + + /** + * Set field values from array + * + * @param array $values Key-value pairs + * @return void + */ + public function setFieldValues($values) + { + $this->field_values = json_encode($values); + } + + /** + * Get single field value + * + * @param string $fieldCode Field code + * @return mixed|null + */ + public function getFieldValue($fieldCode) + { + $values = $this->getFieldValues(); + return isset($values[$fieldCode]) ? $values[$fieldCode] : null; + } + + /** + * Get label for SVG block display + * Combines fields with show_on_block = 1 + * + * @return string Label to display on block (e.g. "B16") + */ + public function getBlockLabel() + { + $type = new EquipmentType($this->db); + if ($type->fetch($this->fk_equipment_type) <= 0) { + return $this->type_label_short ?: ''; + } + + $blockFields = $type->getBlockFields(); + if (empty($blockFields)) { + return $this->type_label_short ?: ''; + } + + $values = $this->getFieldValues(); + $parts = array(); + + foreach ($blockFields as $field) { + if (isset($values[$field->field_code]) && $values[$field->field_code] !== '') { + $parts[] = $values[$field->field_code]; + } + } + + if (empty($parts)) { + return $this->type_label_short ?: ''; + } + + return implode('', $parts); + } + + /** + * Get color for SVG block + * + * @return string Hex color code + */ + public function getBlockColor() + { + if (!empty($this->type_color)) { + return $this->type_color; + } + + // Default color + return '#3498db'; + } +} diff --git a/class/equipmentcarrier.class.php b/class/equipmentcarrier.class.php new file mode 100755 index 0000000..2094417 --- /dev/null +++ b/class/equipmentcarrier.class.php @@ -0,0 +1,426 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->fk_anlage) || empty($this->label)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Get next position + if (empty($this->position)) { + $sql = "SELECT MAX(position) as maxpos FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $this->fk_anlage); + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + $this->position = ($obj->maxpos !== null) ? $obj->maxpos + 1 : 0; + } + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_anlage, fk_panel, label, total_te, position, note_private, status,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $this->fk_anlage); + $sql .= ", ".($this->fk_panel > 0 ? ((int) $this->fk_panel) : "NULL"); + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".((int) ($this->total_te > 0 ? $this->total_te : 12)); + $sql .= ", ".((int) $this->position); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".((int) ($this->status !== null ? $this->status : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT c.*, a.label as anlage_label, p.label as panel_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as a ON c.fk_anlage = a.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_panel as p ON c.fk_panel = p.rowid"; + $sql .= " WHERE c.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_anlage = $obj->fk_anlage; + $this->fk_panel = $obj->fk_panel; + $this->label = $obj->label; + $this->total_te = $obj->total_te; + $this->position = $obj->position; + $this->note_private = $obj->note_private; + $this->status = $obj->status; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->anlage_label = $obj->anlage_label; + $this->panel_label = $obj->panel_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " label = '".$this->db->escape($this->label)."'"; + $sql .= ", fk_panel = ".($this->fk_panel > 0 ? ((int) $this->fk_panel) : "NULL"); + $sql .= ", total_te = ".((int) ($this->total_te > 0 ? $this->total_te : 12)); + $sql .= ", position = ".((int) $this->position); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + $error = 0; + $this->db->begin(); + + // Equipment is deleted via CASCADE + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all carriers for a Panel + * + * @param int $panelId Panel ID + * @param int $activeOnly Only active carriers + * @return array Array of EquipmentCarrier objects + */ + public function fetchByPanel($panelId, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_panel = ".((int) $panelId); + if ($activeOnly) { + $sql .= " AND status = 1"; + } + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $carrier = new EquipmentCarrier($this->db); + $carrier->id = $obj->rowid; + $carrier->entity = $obj->entity; + $carrier->fk_anlage = $obj->fk_anlage; + $carrier->fk_panel = $obj->fk_panel; + $carrier->label = $obj->label; + $carrier->total_te = $obj->total_te; + $carrier->position = $obj->position; + $carrier->note_private = $obj->note_private; + $carrier->status = $obj->status; + + $results[] = $carrier; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch all carriers for an Anlage + * + * @param int $anlageId Anlage ID + * @param int $activeOnly Only active carriers + * @return array Array of EquipmentCarrier objects + */ + public function fetchByAnlage($anlageId, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $anlageId); + if ($activeOnly) { + $sql .= " AND status = 1"; + } + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $carrier = new EquipmentCarrier($this->db); + $carrier->id = $obj->rowid; + $carrier->entity = $obj->entity; + $carrier->fk_anlage = $obj->fk_anlage; + $carrier->fk_panel = $obj->fk_panel; + $carrier->label = $obj->label; + $carrier->total_te = $obj->total_te; + $carrier->position = $obj->position; + $carrier->note_private = $obj->note_private; + $carrier->status = $obj->status; + + $results[] = $carrier; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch all equipment on this carrier + * + * @return array Array of Equipment objects + */ + public function fetchEquipment() + { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipment.class.php'; + + $equipment = new Equipment($this->db); + $this->equipment = $equipment->fetchByCarrier($this->id); + + return $this->equipment; + } + + /** + * Get array of occupied TE slots + * + * @return array Array of occupied slot numbers (0-based) + */ + public function getOccupiedSlots() + { + $occupied = array(); + + if (empty($this->equipment)) { + $this->fetchEquipment(); + } + + foreach ($this->equipment as $eq) { + for ($i = $eq->position_te; $i < $eq->position_te + $eq->width_te; $i++) { + $occupied[] = $i; + } + } + + return $occupied; + } + + /** + * Get used TE count + * + * @return int Number of used TE + */ + public function getUsedTE() + { + return count($this->getOccupiedSlots()); + } + + /** + * Get free TE count + * + * @return int Number of free TE + */ + public function getFreeTE() + { + return $this->total_te - $this->getUsedTE(); + } + + /** + * Find next free position for given width + * + * @param int $width Width in TE needed + * @return int Position (1-based) or -1 if no space + */ + public function getNextFreePosition($width = 1) + { + $occupied = $this->getOccupiedSlots(); + + // Positions are 1-based (1 to total_te) + for ($pos = 1; $pos <= $this->total_te - $width + 1; $pos++) { + $fits = true; + for ($i = $pos; $i < $pos + $width; $i++) { + if (in_array($i, $occupied)) { + $fits = false; + break; + } + } + if ($fits) { + return $pos; + } + } + + return -1; // No space available + } + + /** + * Check if position is available for given width + * + * @param int $position Start position (1-based) + * @param int $width Width in TE + * @param int $excludeEquipmentId Equipment ID to exclude (for updates) + * @return bool True if position is available + */ + public function isPositionAvailable($position, $width, $excludeEquipmentId = 0) + { + // Check bounds (positions are 1-based) + if ($position < 1 || $position + $width - 1 > $this->total_te) { + return false; + } + + if (empty($this->equipment)) { + $this->fetchEquipment(); + } + + foreach ($this->equipment as $eq) { + if ($excludeEquipmentId > 0 && $eq->id == $excludeEquipmentId) { + continue; + } + + // Check for overlap + $eqStart = $eq->position_te; + $eqEnd = $eq->position_te + $eq->width_te - 1; + $newStart = $position; + $newEnd = $position + $width - 1; + + if ($newStart <= $eqEnd && $newEnd >= $eqStart) { + return false; // Overlap + } + } + + return true; + } +} diff --git a/class/equipmentconnection.class.php b/class/equipmentconnection.class.php new file mode 100755 index 0000000..d1e7365 --- /dev/null +++ b/class/equipmentconnection.class.php @@ -0,0 +1,427 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_source, source_terminal, source_terminal_id, fk_target, target_terminal, target_terminal_id,"; + $sql .= " connection_type, color, output_label,"; + $sql .= " medium_type, medium_spec, medium_length,"; + $sql .= " is_rail, rail_start_te, rail_end_te, rail_phases, excluded_te, fk_carrier, position_y, path_data,"; + $sql .= " note_private, status, date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".($this->fk_source > 0 ? ((int) $this->fk_source) : "NULL"); + $sql .= ", '".$this->db->escape($this->source_terminal ?: 'output')."'"; + $sql .= ", ".($this->source_terminal_id ? "'".$this->db->escape($this->source_terminal_id)."'" : "NULL"); + $sql .= ", ".($this->fk_target > 0 ? ((int) $this->fk_target) : "NULL"); + $sql .= ", '".$this->db->escape($this->target_terminal ?: 'input')."'"; + $sql .= ", ".($this->target_terminal_id ? "'".$this->db->escape($this->target_terminal_id)."'" : "NULL"); + $sql .= ", ".($this->connection_type ? "'".$this->db->escape($this->connection_type)."'" : "NULL"); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", ".($this->output_label ? "'".$this->db->escape($this->output_label)."'" : "NULL"); + $sql .= ", ".($this->medium_type ? "'".$this->db->escape($this->medium_type)."'" : "NULL"); + $sql .= ", ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL"); + $sql .= ", ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL"); + $sql .= ", ".((int) $this->is_rail); + $sql .= ", ".($this->rail_start_te > 0 ? ((int) $this->rail_start_te) : "NULL"); + $sql .= ", ".($this->rail_end_te > 0 ? ((int) $this->rail_end_te) : "NULL"); + $sql .= ", ".($this->rail_phases ? "'".$this->db->escape($this->rail_phases)."'" : "NULL"); + $sql .= ", ".($this->excluded_te ? "'".$this->db->escape($this->excluded_te)."'" : "NULL"); + $sql .= ", ".($this->fk_carrier > 0 ? ((int) $this->fk_carrier) : "NULL"); + $sql .= ", ".((int) $this->position_y); + $sql .= ", ".($this->path_data ? "'".$this->db->escape($this->path_data)."'" : "NULL"); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".((int) $this->status); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT c.*, "; + $sql .= " src.label as source_label, tgt.label as target_label, car.label as carrier_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as src ON c.fk_source = src.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as tgt ON c.fk_target = tgt.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment_carrier as car ON c.fk_carrier = car.rowid"; + $sql .= " WHERE c.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_source = $obj->fk_source; + $this->source_terminal = $obj->source_terminal; + $this->source_terminal_id = $obj->source_terminal_id; + $this->fk_target = $obj->fk_target; + $this->target_terminal = $obj->target_terminal; + $this->target_terminal_id = $obj->target_terminal_id; + $this->connection_type = $obj->connection_type; + $this->color = $obj->color; + $this->output_label = $obj->output_label; + $this->medium_type = $obj->medium_type; + $this->medium_spec = $obj->medium_spec; + $this->medium_length = $obj->medium_length; + $this->is_rail = $obj->is_rail; + $this->rail_start_te = $obj->rail_start_te; + $this->rail_end_te = $obj->rail_end_te; + $this->rail_phases = $obj->rail_phases; + $this->excluded_te = $obj->excluded_te; + $this->fk_carrier = $obj->fk_carrier; + $this->position_y = $obj->position_y; + $this->path_data = isset($obj->path_data) ? $obj->path_data : null; + $this->note_private = $obj->note_private; + $this->status = $obj->status; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->source_label = $obj->source_label; + $this->target_label = $obj->target_label; + $this->carrier_label = $obj->carrier_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " fk_source = ".($this->fk_source > 0 ? ((int) $this->fk_source) : "NULL"); + $sql .= ", source_terminal = '".$this->db->escape($this->source_terminal ?: 'output')."'"; + $sql .= ", fk_target = ".($this->fk_target > 0 ? ((int) $this->fk_target) : "NULL"); + $sql .= ", target_terminal = '".$this->db->escape($this->target_terminal ?: 'input')."'"; + $sql .= ", connection_type = ".($this->connection_type ? "'".$this->db->escape($this->connection_type)."'" : "NULL"); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", output_label = ".($this->output_label ? "'".$this->db->escape($this->output_label)."'" : "NULL"); + $sql .= ", medium_type = ".($this->medium_type ? "'".$this->db->escape($this->medium_type)."'" : "NULL"); + $sql .= ", medium_spec = ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL"); + $sql .= ", medium_length = ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL"); + $sql .= ", is_rail = ".((int) $this->is_rail); + $sql .= ", rail_start_te = ".($this->rail_start_te > 0 ? ((int) $this->rail_start_te) : "NULL"); + $sql .= ", rail_end_te = ".($this->rail_end_te > 0 ? ((int) $this->rail_end_te) : "NULL"); + $sql .= ", rail_phases = ".($this->rail_phases ? "'".$this->db->escape($this->rail_phases)."'" : "NULL"); + $sql .= ", excluded_te = ".($this->excluded_te ? "'".$this->db->escape($this->excluded_te)."'" : "NULL"); + $sql .= ", fk_carrier = ".($this->fk_carrier > 0 ? ((int) $this->fk_carrier) : "NULL"); + $sql .= ", position_y = ".((int) $this->position_y); + $sql .= ", path_data = ".($this->path_data ? "'".$this->db->escape($this->path_data)."'" : "NULL"); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + $error = 0; + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all connections for a carrier + * + * @param int $carrierId Carrier ID + * @param int $activeOnly Only active connections + * @return array Array of EquipmentConnection objects + */ + public function fetchByCarrier($carrierId, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT c.*, "; + $sql .= " src.label as source_label, src.position_te as source_pos, src.width_te as source_width,"; + $sql .= " tgt.label as target_label, tgt.position_te as target_pos"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as c"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as src ON c.fk_source = src.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_equipment as tgt ON c.fk_target = tgt.rowid"; + $sql .= " WHERE c.fk_carrier = ".((int) $carrierId); + if ($activeOnly) { + $sql .= " AND c.status = 1"; + } + $sql .= " ORDER BY c.position_y ASC, c.rowid ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $conn = new EquipmentConnection($this->db); + $conn->id = $obj->rowid; + $conn->entity = $obj->entity; + $conn->fk_source = $obj->fk_source; + $conn->source_terminal = $obj->source_terminal; + $conn->source_terminal_id = $obj->source_terminal_id; + $conn->fk_target = $obj->fk_target; + $conn->target_terminal = $obj->target_terminal; + $conn->target_terminal_id = $obj->target_terminal_id; + $conn->connection_type = $obj->connection_type; + $conn->color = $obj->color; + $conn->output_label = $obj->output_label; + $conn->medium_type = $obj->medium_type; + $conn->medium_spec = $obj->medium_spec; + $conn->medium_length = $obj->medium_length; + $conn->is_rail = $obj->is_rail; + $conn->rail_start_te = $obj->rail_start_te; + $conn->rail_end_te = $obj->rail_end_te; + $conn->rail_phases = $obj->rail_phases; + $conn->excluded_te = $obj->excluded_te; + $conn->fk_carrier = $obj->fk_carrier; + $conn->position_y = $obj->position_y; + $conn->path_data = isset($obj->path_data) ? $obj->path_data : null; + $conn->status = $obj->status; + + $conn->source_label = $obj->source_label; + $conn->source_pos = $obj->source_pos; + $conn->source_width = $obj->source_width; + $conn->target_label = $obj->target_label; + $conn->target_pos = $obj->target_pos; + + $results[] = $conn; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch all outputs for an equipment + * + * @param int $equipmentId Equipment ID + * @return array Array of EquipmentConnection objects + */ + public function fetchOutputs($equipmentId) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_source = ".((int) $equipmentId); + $sql .= " AND fk_target IS NULL"; + $sql .= " AND status = 1"; + $sql .= " ORDER BY position_y ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $conn = new EquipmentConnection($this->db); + $conn->id = $obj->rowid; + $conn->fk_source = $obj->fk_source; + $conn->connection_type = $obj->connection_type; + $conn->color = $obj->color; + $conn->output_label = $obj->output_label; + $conn->medium_type = $obj->medium_type; + $conn->medium_spec = $obj->medium_spec; + $conn->medium_length = $obj->medium_length; + $conn->fk_carrier = $obj->fk_carrier; + $conn->position_y = $obj->position_y; + $conn->status = $obj->status; + + $results[] = $conn; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Get display color + * + * @return string Color hex code + */ + public function getColor() + { + if (!empty($this->color)) { + return $this->color; + } + return '#888888'; // Default grey + } + + /** + * Get display label for output + * + * @return string Display label + */ + public function getDisplayLabel() + { + $parts = array(); + + if ($this->output_label) { + $parts[] = $this->output_label; + } + if ($this->medium_type) { + $mediumInfo = $this->medium_type; + if ($this->medium_spec) { + $mediumInfo .= ' '.$this->medium_spec; + } + if ($this->medium_length) { + $mediumInfo .= ' ('.$this->medium_length.')'; + } + $parts[] = $mediumInfo; + } + + return implode(' - ', $parts); + } +} diff --git a/class/equipmentpanel.class.php b/class/equipmentpanel.class.php new file mode 100755 index 0000000..1e368b7 --- /dev/null +++ b/class/equipmentpanel.class.php @@ -0,0 +1,285 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->fk_anlage)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Get next position + if (empty($this->position)) { + $sql = "SELECT MAX(position) as maxpos FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $this->fk_anlage); + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + $this->position = ($obj->maxpos !== null) ? $obj->maxpos + 1 : 0; + } + } + + // Default label if not set + if (empty($this->label)) { + $this->label = 'Feld '.($this->position + 1); + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_anlage, label, position, note_private, status,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $this->fk_anlage); + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".((int) $this->position); + $sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", ".((int) ($this->status !== null ? $this->status : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT p.*, a.label as anlage_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as p"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage as a ON p.fk_anlage = a.rowid"; + $sql .= " WHERE p.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_anlage = $obj->fk_anlage; + $this->label = $obj->label; + $this->position = $obj->position; + $this->note_private = $obj->note_private; + $this->status = $obj->status; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->anlage_label = $obj->anlage_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " label = '".$this->db->escape($this->label)."'"; + $sql .= ", position = ".((int) $this->position); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + $error = 0; + $this->db->begin(); + + // Carriers are deleted via CASCADE or need to be reassigned + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all panels for an Anlage + * + * @param int $anlageId Anlage ID + * @param int $activeOnly Only active panels + * @return array Array of EquipmentPanel objects + */ + public function fetchByAnlage($anlageId, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $anlageId); + if ($activeOnly) { + $sql .= " AND status = 1"; + } + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $panel = new EquipmentPanel($this->db); + $panel->id = $obj->rowid; + $panel->entity = $obj->entity; + $panel->fk_anlage = $obj->fk_anlage; + $panel->label = $obj->label; + $panel->position = $obj->position; + $panel->note_private = $obj->note_private; + $panel->status = $obj->status; + + $results[] = $panel; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch all carriers in this panel + * + * @return array Array of EquipmentCarrier objects + */ + public function fetchCarriers() + { + require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; + + $carrier = new EquipmentCarrier($this->db); + $this->carriers = $carrier->fetchByPanel($this->id); + + return $this->carriers; + } + + /** + * Get total carriers count + * + * @return int Number of carriers + */ + public function getCarrierCount() + { + if (empty($this->carriers)) { + $this->fetchCarriers(); + } + return count($this->carriers); + } +} diff --git a/class/equipmenttype.class.php b/class/equipmenttype.class.php new file mode 100755 index 0000000..0c491cb --- /dev/null +++ b/class/equipmenttype.class.php @@ -0,0 +1,394 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->ref) || empty($this->label) || empty($this->fk_system)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, label_short, description, fk_system,"; + $sql .= " width_te, color, fk_product, terminals_config, flow_direction, terminal_position,"; + $sql .= " picto, icon_file, block_image, is_system, position, active,"; + $sql .= " date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= "0"; // entity 0 = global + $sql .= ", '".$this->db->escape($this->ref)."'"; + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".((int) $this->fk_system); + $sql .= ", ".((int) ($this->width_te > 0 ? $this->width_te : 1)); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", ".($this->terminals_config ? "'".$this->db->escape($this->terminals_config)."'" : "NULL"); + $sql .= ", ".($this->flow_direction ? "'".$this->db->escape($this->flow_direction)."'" : "NULL"); + $sql .= ", '".$this->db->escape($this->terminal_position ?: 'both')."'"; + $sql .= ", ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", ".($this->icon_file ? "'".$this->db->escape($this->icon_file)."'" : "NULL"); + $sql .= ", ".($this->block_image ? "'".$this->db->escape($this->block_image)."'" : "NULL"); + $sql .= ", 0"; // is_system = 0 for user-created + $sql .= ", ".((int) $this->position); + $sql .= ", ".((int) ($this->active !== null ? $this->active : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT t.*, s.label as system_label, s.code as system_code,"; + $sql .= " p.ref as product_ref, p.label as product_label"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON t.fk_product = p.rowid"; + $sql .= " WHERE t.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->label_short = $obj->label_short; + $this->description = $obj->description; + $this->fk_system = $obj->fk_system; + $this->width_te = $obj->width_te; + $this->color = $obj->color; + $this->fk_product = $obj->fk_product; + $this->terminals_config = $obj->terminals_config; + $this->flow_direction = $obj->flow_direction; + $this->terminal_position = $obj->terminal_position ?: 'both'; + $this->picto = $obj->picto; + $this->icon_file = $obj->icon_file; + $this->block_image = $obj->block_image; + $this->is_system = $obj->is_system; + $this->position = $obj->position; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->system_label = $obj->system_label; + $this->system_code = $obj->system_code; + $this->product_ref = $obj->product_ref; + $this->product_label = $obj->product_label; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = '".$this->db->escape($this->ref)."'"; + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", fk_system = ".((int) $this->fk_system); + $sql .= ", width_te = ".((int) ($this->width_te > 0 ? $this->width_te : 1)); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", terminals_config = ".($this->terminals_config ? "'".$this->db->escape($this->terminals_config)."'" : "NULL"); + $sql .= ", flow_direction = ".($this->flow_direction ? "'".$this->db->escape($this->flow_direction)."'" : "NULL"); + $sql .= ", terminal_position = '".$this->db->escape($this->terminal_position ?: 'both')."'"; + $sql .= ", picto = ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", icon_file = ".($this->icon_file ? "'".$this->db->escape($this->icon_file)."'" : "NULL"); + $sql .= ", block_image = ".($this->block_image ? "'".$this->db->escape($this->block_image)."'" : "NULL"); + $sql .= ", position = ".((int) $this->position); + $sql .= ", active = ".((int) $this->active); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + global $conf; + + // Check if type is in use + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_equipment"; + $sql .= " WHERE fk_equipment_type = ".((int) $this->id); + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + if ($obj->cnt > 0) { + $this->error = 'ErrorTypeInUse'; + return -1; + } + } + + // Cannot delete system types + if ($this->is_system) { + $this->error = 'ErrorCannotDeleteSystemType'; + return -2; + } + + $error = 0; + $this->db->begin(); + + // Delete fields first + $sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field WHERE fk_equipment_type = ".((int) $this->id); + $this->db->query($sql); + + // Delete type + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all equipment types for a system + * + * @param int $systemId System ID (0 = all) + * @param int $activeOnly Only active types + * @return array Array of EquipmentType objects + */ + public function fetchAllBySystem($systemId = 0, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE 1 = 1"; + if ($systemId > 0) { + $sql .= " AND t.fk_system = ".((int) $systemId); + } + if ($activeOnly) { + $sql .= " AND t.active = 1"; + } + $sql .= " ORDER BY t.fk_system ASC, t.position ASC, t.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $type = new EquipmentType($this->db); + $type->id = $obj->rowid; + $type->ref = $obj->ref; + $type->label = $obj->label; + $type->label_short = $obj->label_short; + $type->fk_system = $obj->fk_system; + $type->width_te = $obj->width_te; + $type->color = $obj->color; + $type->fk_product = $obj->fk_product; + $type->flow_direction = $obj->flow_direction; + $type->terminal_position = $obj->terminal_position ?: 'both'; + $type->picto = $obj->picto; + $type->icon_file = $obj->icon_file; + $type->block_image = $obj->block_image; + $type->is_system = $obj->is_system; + $type->position = $obj->position; + $type->active = $obj->active; + $type->system_label = $obj->system_label; + $type->system_code = $obj->system_code; + + $results[] = $type; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch fields for this equipment type + * + * @param int $activeOnly Only active fields + * @return array Array of field objects + */ + public function fetchFields($activeOnly = 1) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " WHERE fk_equipment_type = ".((int) $this->id); + if ($activeOnly) { + $sql .= " AND active = 1"; + } + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $results[] = $obj; + } + $this->db->free($resql); + } + + $this->fields = $results; + return $results; + } + + /** + * Get fields that should be shown on the SVG block + * + * @return array Array of field objects with show_on_block = 1 + */ + public function getBlockFields() + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field"; + $sql .= " WHERE fk_equipment_type = ".((int) $this->id); + $sql .= " AND show_on_block = 1"; + $sql .= " AND active = 1"; + $sql .= " ORDER BY position ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $results[] = $obj; + } + $this->db->free($resql); + } + + return $results; + } +} diff --git a/class/favoriteproduct.class.php b/class/favoriteproduct.class.php new file mode 100755 index 0000000..7cb2904 --- /dev/null +++ b/class/favoriteproduct.class.php @@ -0,0 +1,802 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user, $notrigger = false) + { + global $conf; + + $error = 0; + $now = dol_now(); + + // Check parameters + if (empty($this->fk_soc) || empty($this->fk_product)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + // Check if already exists + if ($this->alreadyExists($this->fk_soc, $this->fk_product, $this->fk_contact)) { + $this->error = 'ErrorRecordAlreadyExists'; + return -2; + } + + $this->db->begin(); + + // Get max rang for this customer/contact to add at end of list + $maxRang = 0; + $sqlRang = "SELECT MAX(rang) as maxrang FROM ".MAIN_DB_PREFIX.$this->table_element; + $sqlRang .= " WHERE fk_soc = ".((int) $this->fk_soc); + if ($this->fk_contact > 0) { + $sqlRang .= " AND fk_contact = ".((int) $this->fk_contact); + } else { + $sqlRang .= " AND (fk_contact IS NULL OR fk_contact = 0)"; + } + $sqlRang .= " AND entity = ".((int) $conf->entity); + $resRang = $this->db->query($sqlRang); + if ($resRang) { + $objRang = $this->db->fetch_object($resRang); + $maxRang = ($objRang->maxrang !== null) ? ((int) $objRang->maxrang + 1) : 0; + } + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_soc, fk_contact, fk_product, qty, rang, note, active, date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $this->fk_soc); + $sql .= ", ".($this->fk_contact > 0 ? ((int) $this->fk_contact) : "NULL"); + $sql .= ", ".((int) $this->fk_product); + $sql .= ", ".((float) ($this->qty > 0 ? $this->qty : 1)); + $sql .= ", ".((int) $maxRang); + $sql .= ", ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL"); + $sql .= ", ".((int) ($this->active !== null ? $this->active : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + $this->date_creation = $now; + $this->fk_user_creat = $user->id; + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + global $conf; + + $sql = "SELECT rowid, entity, fk_soc, fk_contact, fk_product, qty, rang, note, active,"; + $sql .= " date_creation, tms, fk_user_creat, fk_user_modif"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".((int) $id); + $sql .= " AND entity = ".((int) $conf->entity); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_soc = $obj->fk_soc; + $this->fk_contact = $obj->fk_contact; + $this->fk_product = $obj->fk_product; + $this->qty = $obj->qty; + $this->rang = $obj->rang; + $this->note = $obj->note; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->tms = $this->db->jdate($obj->tms); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user, $notrigger = false) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " qty = ".((float) $this->qty); + $sql .= ", rang = ".((int) $this->rang); + $sql .= ", note = ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL"); + $sql .= ", active = ".((int) $this->active); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @param bool $notrigger false=launch triggers, true=disable triggers + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user, $notrigger = false) + { + $error = 0; + + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Check if a product is already a favorite for a customer/contact + * + * @param int $socid Customer ID + * @param int $productid Product ID + * @param int $contactid Contact ID (optional) + * @return bool + */ + public function alreadyExists($socid, $productid, $contactid = 0) + { + global $conf; + + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_soc = ".((int) $socid); + $sql .= " AND fk_product = ".((int) $productid); + if ($contactid > 0) { + $sql .= " AND fk_contact = ".((int) $contactid); + } else { + $sql .= " AND (fk_contact IS NULL OR fk_contact = 0)"; + } + $sql .= " AND entity = ".((int) $conf->entity); + + $resql = $this->db->query($sql); + if ($resql) { + return ($this->db->num_rows($resql) > 0); + } + return false; + } + + /** + * Get all favorite products for a customer + * + * @param int $socid Customer ID + * @param int $activeonly Only active favorites + * @return array|int Array of FavoriteProduct objects or -1 if error + */ + public function fetchAllBySociete($socid, $activeonly = 1) + { + global $conf; + + $results = array(); + + $sql = "SELECT fp.rowid, fp.fk_soc, fp.fk_product, fp.qty, fp.rang, fp.note, fp.active,"; + $sql .= " p.ref as product_ref, p.label as product_label, p.price, p.price_ttc, p.tva_tx"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as fp"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON fp.fk_product = p.rowid"; + $sql .= " WHERE fp.fk_soc = ".((int) $socid); + // Only thirdparty-level favorites, not contact-specific + $sql .= " AND (fp.fk_contact IS NULL OR fp.fk_contact = 0)"; + $sql .= " AND fp.entity = ".((int) $conf->entity); + if ($activeonly) { + $sql .= " AND fp.active = 1"; + } + $sql .= " ORDER BY fp.rang ASC, p.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $fav = new FavoriteProduct($this->db); + $fav->id = $obj->rowid; + $fav->fk_soc = $obj->fk_soc; + $fav->fk_product = $obj->fk_product; + $fav->qty = $obj->qty; + $fav->rang = $obj->rang; + $fav->note = $obj->note; + $fav->active = $obj->active; + + // Product info + $fav->product_ref = $obj->product_ref; + $fav->product_label = $obj->product_label; + $fav->product_price = $obj->price; + $fav->product_price_ttc = $obj->price_ttc; + $fav->product_tva_tx = $obj->tva_tx; + + $results[] = $fav; + } + $this->db->free($resql); + return $results; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Move a favorite product up in the list + * + * @param int $id Favorite ID to move + * @param int $socid Customer ID + * @return int 1 if OK, <0 if KO + */ + public function moveUp($id, $socid) + { + return $this->movePosition($id, $socid, 'up'); + } + + /** + * Move a favorite product down in the list + * + * @param int $id Favorite ID to move + * @param int $socid Customer ID + * @return int 1 if OK, <0 if KO + */ + public function moveDown($id, $socid) + { + return $this->movePosition($id, $socid, 'down'); + } + + /** + * Move a favorite product position + * + * @param int $id Favorite ID to move + * @param int $socid Customer ID + * @param string $direction 'up' or 'down' + * @return int 1 if OK, <0 if KO + */ + private function movePosition($id, $socid, $direction) + { + global $conf; + + // Get all favorites ordered by rang + $sql = "SELECT rowid, rang FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_soc = ".((int) $socid); + $sql .= " AND entity = ".((int) $conf->entity); + $sql .= " ORDER BY rang ASC, rowid ASC"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + $items = array(); + $currentIndex = -1; + $i = 0; + while ($obj = $this->db->fetch_object($resql)) { + $items[$i] = array('id' => $obj->rowid, 'rang' => $i); + if ($obj->rowid == $id) { + $currentIndex = $i; + } + $i++; + } + $this->db->free($resql); + + if ($currentIndex < 0) { + return 0; // Item not found + } + + // Calculate new positions + $swapIndex = -1; + if ($direction == 'up' && $currentIndex > 0) { + $swapIndex = $currentIndex - 1; + } elseif ($direction == 'down' && $currentIndex < count($items) - 1) { + $swapIndex = $currentIndex + 1; + } + + if ($swapIndex < 0) { + return 0; // Cannot move + } + + // Swap positions + $this->db->begin(); + + $sql1 = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET rang = ".((int) $swapIndex); + $sql1 .= " WHERE rowid = ".((int) $items[$currentIndex]['id']); + + $sql2 = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET rang = ".((int) $currentIndex); + $sql2 .= " WHERE rowid = ".((int) $items[$swapIndex]['id']); + + if ($this->db->query($sql1) && $this->db->query($sql2)) { + $this->db->commit(); + return 1; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Generate an order from selected favorite products + * + * @param User $user User creating the order + * @param int $socid Customer ID + * @param array $selectedIds Array of favorite product IDs to include + * @param array $quantities Optional array of quantities (id => qty) + * @return int Order ID if OK, <0 if KO + */ + public function generateOrder($user, $socid, $selectedIds, $quantities = array()) + { + global $conf, $langs, $mysoc; + + require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; + require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + + if (empty($selectedIds)) { + $this->error = 'NoProductsSelected'; + return -1; + } + + // Load thirdparty (required for VAT calculation) + $societe = new Societe($this->db); + if ($societe->fetch($socid) <= 0) { + $this->error = 'ErrorLoadingThirdparty'; + return -1; + } + + // Fetch selected favorites + $favorites = $this->fetchAllBySociete($socid); + if (!is_array($favorites)) { + return -1; + } + + // Filter to selected only + $toAdd = array(); + foreach ($favorites as $fav) { + if (in_array($fav->id, $selectedIds)) { + $qty = isset($quantities[$fav->id]) ? (float) $quantities[$fav->id] : $fav->qty; + if ($qty > 0) { + $toAdd[] = array( + 'product_id' => $fav->fk_product, + 'qty' => $qty + ); + } + } + } + + if (empty($toAdd)) { + $this->error = 'NoValidProductsToAdd'; + return -2; + } + + // Create order + $order = new Commande($this->db); + $order->socid = $socid; + $order->thirdparty = $societe; // Required for VAT calculation + $order->date = dol_now(); + $order->ref_client = $societe->name.' - '.$langs->trans('FavoriteProducts'); // Ihr Zeichen + $order->note_private = $langs->trans('OrderGeneratedFromFavorites'); + + $this->db->begin(); + + // First create the order header + $result = $order->create($user); + + if ($result <= 0) { + $this->error = $order->error; + $this->errors = $order->errors; + $this->db->rollback(); + return -3; + } + + // Now add products to the created order + foreach ($toAdd as $item) { + $product = new Product($this->db); + $product->fetch($item['product_id']); + + // Get VAT rate for this product and customer (mysoc = seller, societe = buyer) + $tva_tx = get_default_tva($mysoc, $societe, $product->id); + $localtax1_tx = get_default_localtax($mysoc, $societe, 1, $product->id); + $localtax2_tx = get_default_localtax($mysoc, $societe, 2, $product->id); + + $lineResult = $order->addline( + $product->label, // Description + $product->price, // Unit price HT + $item['qty'], // Quantity + $tva_tx, // VAT rate + $localtax1_tx, // Local tax 1 + $localtax2_tx, // Local tax 2 + $product->id, // Product ID + 0, // Discount + 0, // Info bits + 0, // fk_remise_except + 'HT', // Price base type + 0, // Unit price TTC + '', // Date start + '', // Date end + 0, // Type (0=product) + -1, // Rang + 0, // Special code + 0, // fk_parent_line + 0, // fk_fournprice + 0, // pa_ht + $product->label, // Label + array(), // Array options + $product->fk_unit // Unit + ); + + if ($lineResult < 0) { + $this->error = $order->error; + $this->errors = $order->errors; + $this->db->rollback(); + return -4; + } + } + + $this->db->commit(); + return $order->id; + } + + /** + * Get all favorite products for a contact/address + * + * @param int $contactid Contact ID + * @param int $activeonly Only active favorites + * @return array|int Array of FavoriteProduct objects or -1 if error + */ + public function fetchAllByContact($contactid, $activeonly = 1) + { + global $conf; + + $results = array(); + + $sql = "SELECT fp.rowid, fp.fk_soc, fp.fk_contact, fp.fk_product, fp.qty, fp.rang, fp.note, fp.active,"; + $sql .= " p.ref as product_ref, p.label as product_label, p.price, p.price_ttc, p.tva_tx"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as fp"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON fp.fk_product = p.rowid"; + $sql .= " WHERE fp.fk_contact = ".((int) $contactid); + $sql .= " AND fp.entity = ".((int) $conf->entity); + if ($activeonly) { + $sql .= " AND fp.active = 1"; + } + $sql .= " ORDER BY fp.rang ASC, p.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $fav = new FavoriteProduct($this->db); + $fav->id = $obj->rowid; + $fav->fk_soc = $obj->fk_soc; + $fav->fk_contact = $obj->fk_contact; + $fav->fk_product = $obj->fk_product; + $fav->qty = $obj->qty; + $fav->rang = $obj->rang; + $fav->note = $obj->note; + $fav->active = $obj->active; + + // Product info + $fav->product_ref = $obj->product_ref; + $fav->product_label = $obj->product_label; + $fav->product_price = $obj->price; + $fav->product_price_ttc = $obj->price_ttc; + $fav->product_tva_tx = $obj->tva_tx; + + $results[] = $fav; + } + $this->db->free($resql); + return $results; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Move a favorite product up in the list (for contact) + * + * @param int $id Favorite ID to move + * @param int $contactid Contact ID + * @return int 1 if OK, <0 if KO + */ + public function moveUpByContact($id, $contactid) + { + return $this->movePositionByContact($id, $contactid, 'up'); + } + + /** + * Move a favorite product down in the list (for contact) + * + * @param int $id Favorite ID to move + * @param int $contactid Contact ID + * @return int 1 if OK, <0 if KO + */ + public function moveDownByContact($id, $contactid) + { + return $this->movePositionByContact($id, $contactid, 'down'); + } + + /** + * Move a favorite product position (for contact) + * + * @param int $id Favorite ID to move + * @param int $contactid Contact ID + * @param string $direction 'up' or 'down' + * @return int 1 if OK, <0 if KO + */ + private function movePositionByContact($id, $contactid, $direction) + { + global $conf; + + $sql = "SELECT rowid, rang FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_contact = ".((int) $contactid); + $sql .= " AND entity = ".((int) $conf->entity); + $sql .= " ORDER BY rang ASC, rowid ASC"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + $items = array(); + $currentIndex = -1; + $i = 0; + while ($obj = $this->db->fetch_object($resql)) { + $items[$i] = array('id' => $obj->rowid, 'rang' => $i); + if ($obj->rowid == $id) { + $currentIndex = $i; + } + $i++; + } + $this->db->free($resql); + + if ($currentIndex < 0) { + return 0; + } + + $swapIndex = -1; + if ($direction == 'up' && $currentIndex > 0) { + $swapIndex = $currentIndex - 1; + } elseif ($direction == 'down' && $currentIndex < count($items) - 1) { + $swapIndex = $currentIndex + 1; + } + + if ($swapIndex < 0) { + return 0; + } + + $this->db->begin(); + + $sql1 = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET rang = ".((int) $swapIndex); + $sql1 .= " WHERE rowid = ".((int) $items[$currentIndex]['id']); + + $sql2 = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET rang = ".((int) $currentIndex); + $sql2 .= " WHERE rowid = ".((int) $items[$swapIndex]['id']); + + if ($this->db->query($sql1) && $this->db->query($sql2)) { + $this->db->commit(); + return 1; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Generate an order from selected favorite products (for contact) + * + * @param User $user User creating the order + * @param int $socid Customer ID + * @param int $contactid Contact ID + * @param array $selectedIds Array of favorite product IDs to include + * @param array $quantities Optional array of quantities (id => qty) + * @return int Order ID if OK, <0 if KO + */ + public function generateOrderByContact($user, $socid, $contactid, $selectedIds, $quantities = array()) + { + global $conf, $langs, $mysoc; + + require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; + require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + require_once DOL_DOCUMENT_ROOT.'/contact/class/contact.class.php'; + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + + if (empty($selectedIds)) { + $this->error = 'NoProductsSelected'; + return -1; + } + + // Load thirdparty + $societe = new Societe($this->db); + if ($societe->fetch($socid) <= 0) { + $this->error = 'ErrorLoadingThirdparty'; + return -1; + } + + // Load contact + $contact = new Contact($this->db); + if ($contact->fetch($contactid) <= 0) { + $this->error = 'ErrorLoadingContact'; + return -1; + } + + // Fetch selected favorites + $favorites = $this->fetchAllByContact($contactid); + if (!is_array($favorites)) { + return -1; + } + + // Filter to selected only + $toAdd = array(); + foreach ($favorites as $fav) { + if (in_array($fav->id, $selectedIds)) { + $qty = isset($quantities[$fav->id]) ? (float) $quantities[$fav->id] : $fav->qty; + if ($qty > 0) { + $toAdd[] = array( + 'product_id' => $fav->fk_product, + 'qty' => $qty + ); + } + } + } + + if (empty($toAdd)) { + $this->error = 'NoValidProductsToAdd'; + return -2; + } + + // Create order + $order = new Commande($this->db); + $order->socid = $socid; + $order->thirdparty = $societe; + $order->date = dol_now(); + // Ihr Zeichen: Kunde - Kontakt/Adresse - Favoriten + $order->ref_client = $societe->name.' - '.$contact->getFullName($langs).' - '.$langs->trans('FavoriteProducts'); + $order->note_private = $langs->trans('OrderGeneratedFromFavorites'); + + $this->db->begin(); + + $result = $order->create($user); + + if ($result <= 0) { + $this->error = $order->error; + $this->errors = $order->errors; + $this->db->rollback(); + return -3; + } + + // Add products + foreach ($toAdd as $item) { + $product = new Product($this->db); + $product->fetch($item['product_id']); + + $tva_tx = get_default_tva($mysoc, $societe, $product->id); + $localtax1_tx = get_default_localtax($mysoc, $societe, 1, $product->id); + $localtax2_tx = get_default_localtax($mysoc, $societe, 2, $product->id); + + $lineResult = $order->addline( + $product->label, + $product->price, + $item['qty'], + $tva_tx, + $localtax1_tx, + $localtax2_tx, + $product->id, + 0, 0, 0, 'HT', 0, '', '', 0, -1, 0, 0, 0, 0, + $product->label, + array(), + $product->fk_unit + ); + + if ($lineResult < 0) { + $this->error = $order->error; + $this->errors = $order->errors; + $this->db->rollback(); + return -4; + } + } + + $this->db->commit(); + return $order->id; + } +} diff --git a/class/mediumtype.class.php b/class/mediumtype.class.php new file mode 100755 index 0000000..a417b6a --- /dev/null +++ b/class/mediumtype.class.php @@ -0,0 +1,383 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->ref) || empty($this->label)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, ref, label, label_short, description, fk_system, category,"; + $sql .= " default_spec, available_specs, color, picto, fk_product,"; + $sql .= " is_system, position, active, date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= "0"; // entity 0 = global + $sql .= ", '".$this->db->escape($this->ref)."'"; + $sql .= ", '".$this->db->escape($this->label)."'"; + $sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", ".((int) $this->fk_system); + $sql .= ", ".($this->category ? "'".$this->db->escape($this->category)."'" : "NULL"); + $sql .= ", ".($this->default_spec ? "'".$this->db->escape($this->default_spec)."'" : "NULL"); + $sql .= ", ".($this->available_specs ? "'".$this->db->escape($this->available_specs)."'" : "NULL"); + $sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", 0"; // is_system = 0 for user-created + $sql .= ", ".((int) $this->position); + $sql .= ", ".((int) ($this->active !== null ? $this->active : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE t.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->label = $obj->label; + $this->label_short = $obj->label_short; + $this->description = $obj->description; + $this->fk_system = $obj->fk_system; + $this->category = $obj->category; + $this->default_spec = $obj->default_spec; + $this->available_specs = $obj->available_specs; + $this->color = $obj->color; + $this->picto = $obj->picto; + $this->fk_product = $obj->fk_product; + $this->is_system = $obj->is_system; + $this->position = $obj->position; + $this->active = $obj->active; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->system_label = $obj->system_label; + $this->system_code = $obj->system_code; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " ref = '".$this->db->escape($this->ref)."'"; + $sql .= ", label = '".$this->db->escape($this->label)."'"; + $sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL"); + $sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL"); + $sql .= ", fk_system = ".((int) $this->fk_system); + $sql .= ", category = ".($this->category ? "'".$this->db->escape($this->category)."'" : "NULL"); + $sql .= ", default_spec = ".($this->default_spec ? "'".$this->db->escape($this->default_spec)."'" : "NULL"); + $sql .= ", available_specs = ".($this->available_specs ? "'".$this->db->escape($this->available_specs)."'" : "NULL"); + $sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL"); + $sql .= ", picto = ".($this->picto ? "'".$this->db->escape($this->picto)."'" : "NULL"); + $sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL"); + $sql .= ", position = ".((int) $this->position); + $sql .= ", active = ".((int) $this->active); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + // Cannot delete system types + if ($this->is_system) { + $this->error = 'ErrorCannotDeleteSystemType'; + return -2; + } + + $error = 0; + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all medium types for a system + * + * @param int $systemId System ID (0 = all) + * @param int $activeOnly Only active types + * @return array Array of MediumType objects + */ + public function fetchAllBySystem($systemId = 0, $activeOnly = 1) + { + $results = array(); + + $sql = "SELECT t.*, s.label as system_label, s.code as system_code"; + $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid"; + $sql .= " WHERE 1 = 1"; + if ($systemId > 0) { + // Show types for this system AND global types (fk_system = 0) + $sql .= " AND (t.fk_system = ".((int) $systemId)." OR t.fk_system = 0)"; + } + if ($activeOnly) { + $sql .= " AND t.active = 1"; + } + $sql .= " ORDER BY t.category ASC, t.position ASC, t.label ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $type = new MediumType($this->db); + $type->id = $obj->rowid; + $type->ref = $obj->ref; + $type->label = $obj->label; + $type->label_short = $obj->label_short; + $type->description = $obj->description; + $type->fk_system = $obj->fk_system; + $type->category = $obj->category; + $type->default_spec = $obj->default_spec; + $type->available_specs = $obj->available_specs; + $type->color = $obj->color; + $type->picto = $obj->picto; + $type->fk_product = $obj->fk_product; + $type->is_system = $obj->is_system; + $type->position = $obj->position; + $type->active = $obj->active; + $type->system_label = $obj->system_label; + $type->system_code = $obj->system_code; + + $results[] = $type; + } + $this->db->free($resql); + } + + return $results; + } + + /** + * Fetch all types grouped by category + * + * @param int $systemId System ID (0 = all) + * @return array Associative array: category => array of MediumType objects + */ + public function fetchGroupedByCategory($systemId = 0) + { + $all = $this->fetchAllBySystem($systemId, 1); + $grouped = array(); + + foreach ($all as $type) { + $cat = $type->category ?: 'sonstiges'; + if (!isset($grouped[$cat])) { + $grouped[$cat] = array(); + } + $grouped[$cat][] = $type; + } + + return $grouped; + } + + /** + * Get available specs as array + * + * @return array Array of specification strings + */ + public function getAvailableSpecsArray() + { + if (empty($this->available_specs)) { + return array(); + } + $specs = json_decode($this->available_specs, true); + return is_array($specs) ? $specs : array(); + } + + /** + * Get category label + * + * @return string Translated category label + */ + public function getCategoryLabel() + { + global $langs; + + switch ($this->category) { + case self::CAT_STROMKABEL: + return $langs->trans('MediumCatStromkabel'); + case self::CAT_NETZWERKKABEL: + return $langs->trans('MediumCatNetzwerkkabel'); + case self::CAT_LWL: + return $langs->trans('MediumCatLWL'); + case self::CAT_KOAX: + return $langs->trans('MediumCatKoax'); + case self::CAT_SONSTIGES: + default: + return $langs->trans('MediumCatSonstiges'); + } + } + + /** + * Get all category options + * + * @return array category_code => translated_label + */ + public static function getCategoryOptions() + { + global $langs; + + return array( + self::CAT_STROMKABEL => $langs->trans('MediumCatStromkabel'), + self::CAT_NETZWERKKABEL => $langs->trans('MediumCatNetzwerkkabel'), + self::CAT_LWL => $langs->trans('MediumCatLWL'), + self::CAT_KOAX => $langs->trans('MediumCatKoax'), + self::CAT_SONSTIGES => $langs->trans('MediumCatSonstiges') + ); + } +} diff --git a/class/terminalbridge.class.php b/class/terminalbridge.class.php new file mode 100755 index 0000000..c626a63 --- /dev/null +++ b/class/terminalbridge.class.php @@ -0,0 +1,257 @@ +db = $db; + } + + /** + * Create object in database + * + * @param User $user User that creates + * @return int Return integer <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $error = 0; + $now = dol_now(); + + if (empty($this->fk_anlage) || empty($this->fk_carrier) || empty($this->start_te) || empty($this->end_te)) { + $this->error = 'ErrorMissingParameters'; + return -1; + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; + $sql .= "entity, fk_anlage, fk_carrier, start_te, end_te,"; + $sql .= " terminal_side, terminal_row, color, bridge_type, label,"; + $sql .= " status, date_creation, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) ($conf->entity)); + $sql .= ", ".((int) $this->fk_anlage); + $sql .= ", ".((int) $this->fk_carrier); + $sql .= ", ".((int) $this->start_te); + $sql .= ", ".((int) $this->end_te); + $sql .= ", '".$this->db->escape($this->terminal_side ?: 'top')."'"; + $sql .= ", ".((int) $this->terminal_row); + $sql .= ", '".$this->db->escape($this->color ?: '#e74c3c')."'"; + $sql .= ", '".$this->db->escape($this->bridge_type ?: 'standard')."'"; + $sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", ".((int) ($this->status !== null ? $this->status : 1)); + $sql .= ", '".$this->db->idate($now)."'"; + $sql .= ", ".((int) $user->id); + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if (!$error) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return $this->id; + } + } + + /** + * Load object from database + * + * @param int $id ID of record + * @return int Return integer <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->fk_anlage = $obj->fk_anlage; + $this->fk_carrier = $obj->fk_carrier; + $this->start_te = $obj->start_te; + $this->end_te = $obj->end_te; + $this->terminal_side = $obj->terminal_side; + $this->terminal_row = $obj->terminal_row; + $this->color = $obj->color; + $this->bridge_type = $obj->bridge_type; + $this->label = $obj->label; + $this->status = $obj->status; + $this->date_creation = $this->db->jdate($obj->date_creation); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update object in database + * + * @param User $user User that modifies + * @return int Return integer <0 if KO, >0 if OK + */ + public function update($user) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " fk_anlage = ".((int) $this->fk_anlage); + $sql .= ", fk_carrier = ".((int) $this->fk_carrier); + $sql .= ", start_te = ".((int) $this->start_te); + $sql .= ", end_te = ".((int) $this->end_te); + $sql .= ", terminal_side = '".$this->db->escape($this->terminal_side ?: 'top')."'"; + $sql .= ", terminal_row = ".((int) $this->terminal_row); + $sql .= ", color = '".$this->db->escape($this->color ?: '#e74c3c')."'"; + $sql .= ", bridge_type = '".$this->db->escape($this->bridge_type ?: 'standard')."'"; + $sql .= ", label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Delete object in database + * + * @param User $user User that deletes + * @return int Return integer <0 if KO, >0 if OK + */ + public function delete($user) + { + $error = 0; + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $error++; + $this->errors[] = "Error ".$this->db->lasterror(); + } + + if ($error) { + $this->db->rollback(); + return -1 * $error; + } else { + $this->db->commit(); + return 1; + } + } + + /** + * Fetch all bridges for an installation + * + * @param int $anlageId Installation ID + * @param int $carrierId Optional carrier ID filter + * @return array Array of TerminalBridge objects + */ + public function fetchAllByAnlage($anlageId, $carrierId = 0) + { + $results = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE fk_anlage = ".((int) $anlageId); + $sql .= " AND status = 1"; + if ($carrierId > 0) { + $sql .= " AND fk_carrier = ".((int) $carrierId); + } + $sql .= " ORDER BY fk_carrier ASC, terminal_side ASC, start_te ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $bridge = new TerminalBridge($this->db); + $bridge->id = $obj->rowid; + $bridge->entity = $obj->entity; + $bridge->fk_anlage = $obj->fk_anlage; + $bridge->fk_carrier = $obj->fk_carrier; + $bridge->start_te = $obj->start_te; + $bridge->end_te = $obj->end_te; + $bridge->terminal_side = $obj->terminal_side; + $bridge->terminal_row = $obj->terminal_row; + $bridge->color = $obj->color; + $bridge->bridge_type = $obj->bridge_type; + $bridge->label = $obj->label; + $bridge->status = $obj->status; + + $results[] = $bridge; + } + $this->db->free($resql); + } + + return $results; + } +} diff --git a/core/modules/modKundenKarte.class.php b/core/modules/modKundenKarte.class.php new file mode 100755 index 0000000..6fa99e8 --- /dev/null +++ b/core/modules/modKundenKarte.class.php @@ -0,0 +1,776 @@ + + * Copyright (C) 2018-2019 Nicolas ZABOURI + * Copyright (C) 2019-2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \defgroup kundenkarte Module KundenKarte + * \brief KundenKarte module descriptor. + * + * \file htdocs/kundenkarte/core/modules/modKundenKarte.class.php + * \ingroup kundenkarte + * \brief Description and activation file for module KundenKarte + */ +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + + +/** + * Description and activation class for module KundenKarte + */ +class modKundenKarte extends DolibarrModules +{ + /** + * Constructor. Define names, constants, directories, boxes, permissions + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf, $langs; + + $this->db = $db; + + // Id for module (must be unique). + // Use here a free id (See in Home -> System information -> Dolibarr for list of used modules id). + $this->numero = 500015; // TODO Go on page https://wiki.dolibarr.org/index.php/List_of_modules_id to reserve an id number for your module + + // Key text used to identify module (for permissions, menus, etc...) + $this->rights_class = 'kundenkarte'; + + // Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...' + // It is used to group modules by family in module setup page + $this->family = "other"; + + // Module position in the family on 2 digits ('01', '10', '20', ...) + $this->module_position = '90'; + + // Gives the possibility for the module, to provide his own family info and position of this family (Overwrite $this->family and $this->module_position. Avoid this) + //$this->familyinfo = array('myownfamily' => array('position' => '01', 'label' => $langs->trans("MyOwnFamily"))); + // Module label (no space allowed), used if translation string 'ModuleKundenKarteName' not found (KundenKarte is name of module). + $this->name = preg_replace('/^mod/i', '', get_class($this)); + + // DESCRIPTION_FLAG + // Module description, used if translation string 'ModuleKundenKarteDesc' not found (KundenKarte is name of module). + $this->description = "KundenKarteDescription"; + // Used only if file README.md and README-LL.md not found. + $this->descriptionlong = "KundenKarteDescription"; + + // Author + $this->editor_name = 'Alles Watt läuft (Testsystem)'; + $this->editor_url = ''; // Must be an external online web site + $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte' + + // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' + $this->version = '5.2.0'; + // Url to the file with your last numberversion of this module + //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; + + // Key used in llx_const table to save module status enabled/disabled (where KUNDENKARTE is value of property name of module in uppercase) + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + + // Name of image file used for this module. + // If file is in theme/yourtheme/img directory under name object_pictovalue.png, use this->picto='pictovalue' + // If file is in module/img directory under name object_pictovalue.png, use this->picto='pictovalue@module' + // To use a supported fa-xxx css style of font awesome, use this->picto='xxx' + $this->picto = 'fa-id-card'; + + // Define some features supported by module (triggers, login, substitutions, menus, css, etc...) + $this->module_parts = array( + // Set this to 1 if module has its own trigger directory (core/triggers) + 'triggers' => 0, + // Set this to 1 if module has its own login method file (core/login) + 'login' => 0, + // Set this to 1 if module has its own substitution function file (core/substitutions) + 'substitutions' => 0, + // Set this to 1 if module has its own menus handler directory (core/menus) + 'menus' => 0, + // Set this to 1 if module overwrite template dir (core/tpl) + 'tpl' => 0, + // Set this to 1 if module has its own barcode directory (core/modules/barcode) + 'barcode' => 0, + // Set this to 1 if module has its own models directory (core/modules/xxx) + 'models' => 0, + // Set this to 1 if module has its own printing directory (core/modules/printing) + 'printing' => 0, + // Set this to 1 if module has its own theme directory (theme) + 'theme' => 0, + // Set this to relative path of css file if module has its own css file + 'css' => array( + '/kundenkarte/css/kundenkarte.css', + ), + // Set this to relative path of js file if module must load a js on all pages + // DISABLED: Loading 438KB JS globally causes conflicts with Dolibarr file upload + // The JS is now loaded only on kundenkarte pages via explicit include + 'js' => array( + // '/kundenkarte/js/kundenkarte.js', + ), + // Set here all hooks context managed by module. To find available hook context, make a "grep -r '>initHooks(' *" on source code. You can also set hook context to 'all' + /* BEGIN MODULEBUILDER HOOKSCONTEXTS */ + 'hooks' => array( + // 'data' => array( + // 'hookcontext1', + // 'hookcontext2', + // ), + // 'entity' => '0', + ), + /* END MODULEBUILDER HOOKSCONTEXTS */ + // Set this to 1 if features of module are opened to external users + 'moduleforexternal' => 0, + // Set this to 1 if the module provides a website template into doctemplates/websites/website_template-mytemplate + 'websitetemplates' => 0, + // Set this to 1 if the module provides a captcha driver + 'captcha' => 0 + ); + + // Data directories to create when module is enabled. + $this->dirs = array( + "/kundenkarte/temp", + "/kundenkarte/anlagen" + ); + + // Config pages. Put here list of php page, stored into kundenkarte/admin directory, to use to setup module. + $this->config_page_url = array("setup.php@kundenkarte"); + + // Dependencies + // A condition to hide module + $this->hidden = getDolGlobalInt('MODULE_KUNDENKARTE_DISABLED'); // A condition to disable module; + // List of module class names that must be enabled if this module is enabled. Example: array('always'=>array('modModuleToEnable1','modModuleToEnable2'), 'FR'=>array('modModuleToEnableFR')...) + $this->depends = array(); + // List of module class names to disable if this one is disabled. Example: array('modModuleToDisable1', ...) + $this->requiredby = array(); + // List of module class names this module is in conflict with. Example: array('modModuleToDisable1', ...) + $this->conflictwith = array(); + + // The language file dedicated to your module + $this->langfiles = array("kundenkarte@kundenkarte"); + + // Prerequisites + $this->phpmin = array(7, 1); // Minimum version of PHP required by module + // $this->phpmax = array(8, 0); // Maximum version of PHP required by module + $this->need_dolibarr_version = array(19, -3); // Minimum version of Dolibarr required by module + // $this->max_dolibarr_version = array(19, -3); // Maximum version of Dolibarr required by module + $this->need_javascript_ajax = 0; + + // Messages at activation + $this->warnings_activation = array(); // Warning to show when we activate module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + $this->warnings_activation_ext = array(); // Warning to show when we activate an external module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + //$this->automatic_activation = array('FR'=>'KundenKarteWasAutomaticallyActivatedBecauseOfYourCountryChoice'); + //$this->always_enabled = true; // If true, can't be disabled + + // Constants + // List of particular constants to add when module is enabled (key, 'chaine', value, desc, visible, 'current' or 'allentities', deleteonunactive) + // Example: $this->const=array(1 => array('KUNDENKARTE_MYNEWCONST1', 'chaine', 'myvalue', 'This is a constant to add', 1), + // 2 => array('KUNDENKARTE_MYNEWCONST2', 'chaine', 'myvalue', 'This is another constant to add', 0, 'current', 1) + // ); + $this->const = array(); + + // Some keys to add into the overwriting translation tables + /*$this->overwrite_translation = array( + 'en_US:ParentCompany'=>'Parent company or reseller', + 'fr_FR:ParentCompany'=>'Maison mère ou revendeur' + )*/ + + if (!isModEnabled("kundenkarte")) { + $conf->kundenkarte = new stdClass(); + $conf->kundenkarte->enabled = 0; + } + + // Array to add new pages in new tabs + /* BEGIN MODULEBUILDER TABS */ + $this->tabs = array( + // Add tab for favorite products on thirdparty card + array('data' => 'thirdparty:+favoriteproducts:FavoriteProducts:kundenkarte@kundenkarte:$user->hasRight(\'kundenkarte\', \'read\'):/kundenkarte/tabs/favoriteproducts.php?id=__ID__'), + // Add tab for technical installations on thirdparty card + array('data' => 'thirdparty:+anlagen:TechnicalInstallations:kundenkarte@kundenkarte:$user->hasRight(\'kundenkarte\', \'read\'):/kundenkarte/tabs/anlagen.php?id=__ID__'), + // Add tab for favorite products on contact card (for addresses/buildings) + array('data' => 'contact:+favoriteproducts:FavoriteProducts:kundenkarte@kundenkarte:$user->hasRight(\'kundenkarte\', \'read\'):/kundenkarte/tabs/contact_favoriteproducts.php?id=__ID__'), + // Add tab for technical installations on contact card (for addresses/buildings) + array('data' => 'contact:+anlagen:TechnicalInstallations:kundenkarte@kundenkarte:$user->hasRight(\'kundenkarte\', \'read\'):/kundenkarte/tabs/contact_anlagen.php?id=__ID__'), + ); + /* END MODULEBUILDER TABS */ + // Example: + // To add a new tab identified by code tabname1 + // $this->tabs[] = array('data' => 'objecttype:+tabname1:Title1:mylangfile@kundenkarte:$user->hasRight(\'kundenkarte\', \'read\'):/kundenkarte/mynewtab1.php?id=__ID__'); + // To add another new tab identified by code tabname2. Label will be result of calling all substitution functions on 'Title2' key. + // $this->tabs[] = array('data' => 'objecttype:+tabname2:SUBSTITUTION_Title2:mylangfile@kundenkarte:$user->hasRight(\'othermodule\', \'read\'):/kundenkarte/mynewtab2.php?id=__ID__', + // To remove an existing tab identified by code tabname + // $this->tabs[] = array('data' => 'objecttype:-tabname:NU:conditiontoremove'); + // + // Where objecttype can be + // 'categories_x' to add a tab in category view (replace 'x' by type of category (0=product, 1=supplier, 2=customer, 3=member) + // 'contact' to add a tab in contact view + // 'contract' to add a tab in contract view + // 'delivery' to add a tab in delivery view + // 'group' to add a tab in group view + // 'intervention' to add a tab in intervention view + // 'invoice' to add a tab in customer invoice view + // 'supplier_invoice' to add a tab in supplier invoice view + // 'member' to add a tab in foundation member view + // 'opensurveypoll' to add a tab in opensurvey poll view + // 'order' to add a tab in sale order view + // 'supplier_order' to add a tab in supplier order view + // 'payment' to add a tab in payment view + // 'supplier_payment' to add a tab in supplier payment view + // 'product' to add a tab in product view + // 'propal' to add a tab in propal view + // 'project' to add a tab in project view + // 'stock' to add a tab in stock view + // 'thirdparty' to add a tab in third party view + // 'user' to add a tab in user view + + + // Dictionaries + /* Example: + $this->dictionaries=array( + 'langs' => 'kundenkarte@kundenkarte', + // List of tables we want to see into dictionary editor + 'tabname' => array("table1", "table2", "table3"), + // Label of tables + 'tablib' => array("Table1", "Table2", "Table3"), + // Request to select fields + 'tabsql' => array('SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table1 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table2 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table3 as f'), + // Sort order + 'tabsqlsort' => array("label ASC", "label ASC", "label ASC"), + // List of fields (result of select to show dictionary) + 'tabfield' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields to edit a record) + 'tabfieldvalue' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields for insert) + 'tabfieldinsert' => array("code,label", "code,label", "code,label"), + // Name of columns with primary key (try to always name it 'rowid') + 'tabrowid' => array("rowid", "rowid", "rowid"), + // Condition to show each dictionary + 'tabcond' => array(isModEnabled('kundenkarte'), isModEnabled('kundenkarte'), isModEnabled('kundenkarte')), + // Tooltip for every fields of dictionaries: DO NOT PUT AN EMPTY ARRAY + 'tabhelp' => array(array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), ...), + ); + */ + /* BEGIN MODULEBUILDER DICTIONARIES */ + $this->dictionaries = array(); + /* END MODULEBUILDER DICTIONARIES */ + + // Boxes/Widgets + // Add here list of php file(s) stored in kundenkarte/core/boxes that contains a class to show a widget. + /* BEGIN MODULEBUILDER WIDGETS */ + $this->boxes = array( + // 0 => array( + // 'file' => 'kundenkartewidget1.php@kundenkarte', + // 'note' => 'Widget provided by KundenKarte', + // 'enabledbydefaulton' => 'Home', + // ), + // ... + ); + /* END MODULEBUILDER WIDGETS */ + + // Cronjobs (List of cron jobs entries to add when module is enabled) + // unit_frequency must be 60 for minute, 3600 for hour, 86400 for day, 604800 for week + /* BEGIN MODULEBUILDER CRON */ + $this->cronjobs = array( + // 0 => array( + // 'label' => 'MyJob label', + // 'jobtype' => 'method', + // 'class' => '/kundenkarte/class/myobject.class.php', + // 'objectname' => 'MyObject', + // 'method' => 'doScheduledJob', + // 'parameters' => '', + // 'comment' => 'Comment', + // 'frequency' => 2, + // 'unitfrequency' => 3600, + // 'status' => 0, + // 'test' => 'isModEnabled("kundenkarte")', + // 'priority' => 50, + // ), + ); + /* END MODULEBUILDER CRON */ + // Example: $this->cronjobs=array( + // 0=>array('label'=>'My label', 'jobtype'=>'method', 'class'=>'/dir/class/file.class.php', 'objectname'=>'MyClass', 'method'=>'myMethod', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>2, 'unitfrequency'=>3600, 'status'=>0, 'test'=>'isModEnabled("kundenkarte")', 'priority'=>50), + // 1=>array('label'=>'My label', 'jobtype'=>'command', 'command'=>'', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>1, 'unitfrequency'=>3600*24, 'status'=>0, 'test'=>'isModEnabled("kundenkarte")', 'priority'=>50) + // ); + + // Permissions provided by this module + $this->rights = array(); + $r = 0; + // Add here entries to declare new permissions + /* BEGIN MODULEBUILDER PERMISSIONS */ + + // Read permission + $this->rights[$r][0] = $this->numero . sprintf("%02d", 1); + $this->rights[$r][1] = 'Read KundenKarte data'; + $this->rights[$r][4] = 'read'; + $this->rights[$r][5] = ''; + $r++; + + // Write permission + $this->rights[$r][0] = $this->numero . sprintf("%02d", 2); + $this->rights[$r][1] = 'Create/Update KundenKarte data'; + $this->rights[$r][4] = 'write'; + $this->rights[$r][5] = ''; + $r++; + + // Delete permission + $this->rights[$r][0] = $this->numero . sprintf("%02d", 3); + $this->rights[$r][1] = 'Delete KundenKarte data'; + $this->rights[$r][4] = 'delete'; + $this->rights[$r][5] = ''; + $r++; + + // Admin permission for managing types and systems + $this->rights[$r][0] = $this->numero . sprintf("%02d", 4); + $this->rights[$r][1] = 'Administer KundenKarte settings'; + $this->rights[$r][4] = 'admin'; + $this->rights[$r][5] = ''; + $r++; + + /* END MODULEBUILDER PERMISSIONS */ + + + // Main menu entries to add + $this->menu = array(); + $r = 0; + // Add here entries to declare new menus + /* BEGIN MODULEBUILDER TOPMENU + $this->menu[$r++] = array( + 'fk_menu' => '', // Will be stored into mainmenu + leftmenu. Use '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'top', // This is a Top menu entry + 'titre' => 'ModuleKundenKarteName', + 'prefix' => img_picto('', $this->picto, 'class="pictofixedwidth valignmiddle"'), + 'mainmenu' => 'kundenkarte', + 'leftmenu' => '', + 'url' => '/kundenkarte/kundenkarteindex.php', + 'langs' => 'kundenkarte@kundenkarte', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("kundenkarte")', // Define condition to show or hide menu entry. Use 'isModEnabled("kundenkarte")' if entry must be visible if module is enabled. + 'perms' => '1', // Use 'perms'=>'$user->hasRight("kundenkarte", "myobject", "read")' if you want your menu with a permission rules + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + );*/ + /* END MODULEBUILDER TOPMENU */ + + /* BEGIN MODULEBUILDER LEFTMENU */ + // Admin submenu: Manage Systems + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=kundenkarte', + 'type' => 'left', + 'titre' => 'AnlagenSystems', + 'prefix' => img_picto('', 'fa-cogs', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'kundenkarte', + 'leftmenu' => 'kundenkarte_systems', + 'url' => '/kundenkarte/admin/anlage_systems.php', + 'langs' => 'kundenkarte@kundenkarte', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("kundenkarte")', + 'perms' => '$user->hasRight("kundenkarte", "admin")', + 'target' => '', + 'user' => 0, + ); + + // Admin submenu: Manage Element Types + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=kundenkarte', + 'type' => 'left', + 'titre' => 'AnlagenTypes', + 'prefix' => img_picto('', 'fa-list', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'kundenkarte', + 'leftmenu' => 'kundenkarte_types', + 'url' => '/kundenkarte/admin/anlage_types.php', + 'langs' => 'kundenkarte@kundenkarte', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("kundenkarte")', + 'perms' => '$user->hasRight("kundenkarte", "admin")', + 'target' => '', + 'user' => 0, + ); + + // Admin submenu: Manage Equipment Types + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=kundenkarte', + 'type' => 'left', + 'titre' => 'EquipmentTypes', + 'prefix' => img_picto('', 'fa-microchip', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'kundenkarte', + 'leftmenu' => 'kundenkarte_equipment_types', + 'url' => '/kundenkarte/admin/equipment_types.php', + 'langs' => 'kundenkarte@kundenkarte', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("kundenkarte")', + 'perms' => '$user->hasRight("kundenkarte", "admin")', + 'target' => '', + 'user' => 0, + ); + /* END MODULEBUILDER LEFTMENU */ + + + // Exports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER EXPORT MYOBJECT */ + /* + $langs->load("kundenkarte@kundenkarte"); + $this->export_code[$r] = $this->rights_class.'_'.$r; + $this->export_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->export_icon[$r] = $this->picto; + // Define $this->export_fields_array, $this->export_TypeFields_array and $this->export_entities_array + $keyforclass = 'MyObject'; $keyforclassfile='/kundenkarte/class/myobject.class.php'; $keyforelement='myobject@kundenkarte'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + //$this->export_fields_array[$r]['t.fieldtoadd']='FieldToAdd'; $this->export_TypeFields_array[$r]['t.fieldtoadd']='Text'; + //unset($this->export_fields_array[$r]['t.fieldtoremove']); + //$keyforclass = 'MyObjectLine'; $keyforclassfile='/kundenkarte/class/myobject.class.php'; $keyforelement='myobjectline@kundenkarte'; $keyforalias='tl'; + //include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@kundenkarte'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$keyforselect='myobjectline'; $keyforaliasextra='extraline'; $keyforelement='myobjectline@kundenkarte'; + //include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$this->export_dependencies_array[$r] = array('myobjectline' => array('tl.rowid','tl.ref')); // To force to activate one or several fields if we select some fields that need same (like to select a unique key if we ask a field of a child to avoid the DISTINCT to discard them, or for computed field than need several other fields) + //$this->export_special_array[$r] = array('t.field' => '...'); + //$this->export_examplevalues_array[$r] = array('t.field' => 'Example'); + //$this->export_help_array[$r] = array('t.field' => 'FieldDescHelp'); + $this->export_sql_start[$r]='SELECT DISTINCT '; + $this->export_sql_end[$r] =' FROM '.$this->db->prefix().'kundenkarte_myobject as t'; + //$this->export_sql_end[$r] .=' LEFT JOIN '.$this->db->prefix().'kundenkarte_myobject_line as tl ON tl.fk_myobject = t.rowid'; + $this->export_sql_end[$r] .=' WHERE 1 = 1'; + $this->export_sql_end[$r] .=' AND t.entity IN ('.getEntity('myobject').')'; + $r++; */ + /* END MODULEBUILDER EXPORT MYOBJECT */ + + // Imports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER IMPORT MYOBJECT */ + /* + $langs->load("kundenkarte@kundenkarte"); + $this->import_code[$r] = $this->rights_class.'_'.$r; + $this->import_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->import_icon[$r] = $this->picto; + $this->import_tables_array[$r] = array('t' => $this->db->prefix().'kundenkarte_myobject', 'extra' => $this->db->prefix().'kundenkarte_myobject_extrafields'); + $this->import_tables_creator_array[$r] = array('t' => 'fk_user_author'); // Fields to store import user id + $import_sample = array(); + $keyforclass = 'MyObject'; $keyforclassfile='/kundenkarte/class/myobject.class.php'; $keyforelement='myobject@kundenkarte'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinimport.inc.php'; + $import_extrafield_sample = array(); + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@kundenkarte'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinimport.inc.php'; + $this->import_fieldshidden_array[$r] = array('extra.fk_object' => 'lastrowid-'.$this->db->prefix().'kundenkarte_myobject'); + $this->import_regex_array[$r] = array(); + $this->import_examplevalues_array[$r] = array_merge($import_sample, $import_extrafield_sample); + $this->import_updatekeys_array[$r] = array('t.ref' => 'Ref'); + $this->import_convertvalue_array[$r] = array( + 't.ref' => array( + 'rule'=>'getrefifauto', + 'class'=>(!getDolGlobalString('KUNDENKARTE_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('KUNDENKARTE_MYOBJECT_ADDON')), + 'path'=>"/core/modules/kundenkarte/".(!getDolGlobalString('KUNDENKARTE_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('KUNDENKARTE_MYOBJECT_ADDON')).'.php', + 'classobject'=>'MyObject', + 'pathobject'=>'/kundenkarte/class/myobject.class.php', + ), + 't.fk_soc' => array('rule' => 'fetchidfromref', 'file' => '/societe/class/societe.class.php', 'class' => 'Societe', 'method' => 'fetch', 'element' => 'ThirdParty'), + 't.fk_user_valid' => array('rule' => 'fetchidfromref', 'file' => '/user/class/user.class.php', 'class' => 'User', 'method' => 'fetch', 'element' => 'user'), + 't.fk_mode_reglement' => array('rule' => 'fetchidfromcodeorlabel', 'file' => '/compta/paiement/class/cpaiement.class.php', 'class' => 'Cpaiement', 'method' => 'fetch', 'element' => 'cpayment'), + ); + $this->import_run_sql_after_array[$r] = array(); + $r++; */ + /* END MODULEBUILDER IMPORT MYOBJECT */ + } + + /** + * Function called when module is enabled. + * The init function add constants, boxes, permissions and menus (defined in constructor) into Dolibarr database. + * It also creates data directories + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function init($options = '') + { + global $conf, $langs; + + // Create tables of module at module activation + //$result = $this->_load_tables('/install/mysql/', 'kundenkarte'); + $result = $this->_load_tables('/kundenkarte/sql/'); + if ($result < 0) { + return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default') + } + + // Create extrafields during init + //include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php'; + //$extrafields = new ExtraFields($this->db); + //$result0=$extrafields->addExtraField('kundenkarte_separator1', "Separator 1", 'separator', 1, 0, 'thirdparty', 0, 0, '', array('options'=>array(1=>1)), 1, '', 1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + //$result1=$extrafields->addExtraField('kundenkarte_myattr1', "New Attr 1 label", 'boolean', 1, 3, 'thirdparty', 0, 0, '', '', 1, '', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + //$result2=$extrafields->addExtraField('kundenkarte_myattr2', "New Attr 2 label", 'varchar', 1, 10, 'project', 0, 0, '', '', 1, '', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + //$result3=$extrafields->addExtraField('kundenkarte_myattr3', "New Attr 3 label", 'varchar', 1, 10, 'bank_account', 0, 0, '', '', 1, '', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + //$result4=$extrafields->addExtraField('kundenkarte_myattr4', "New Attr 4 label", 'select', 1, 3, 'thirdparty', 0, 1, '', array('options'=>array('code1'=>'Val1','code2'=>'Val2','code3'=>'Val3')), 1,'', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + //$result5=$extrafields->addExtraField('kundenkarte_myattr5', "New Attr 5 label", 'text', 1, 10, 'user', 0, 0, '', '', 1, '', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")'); + + // Migrationen: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern + $this->_migrateSocieteSystemUniqueKey(); + + // Run all database migrations + $this->runMigrations(); + + // Permissions + $this->remove($options); + + $sql = array(); + + // Document templates + $moduledir = dol_sanitizeFileName('kundenkarte'); + $myTmpObjects = array(); + $myTmpObjects['MyObject'] = array('includerefgeneration' => 0, 'includedocgeneration' => 0); + + foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { + if ($myTmpObjectArray['includerefgeneration']) { + $src = DOL_DOCUMENT_ROOT.'/install/doctemplates/'.$moduledir.'/template_myobjects.odt'; + $dirodt = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/doctemplates/'.$moduledir; + $dest = $dirodt.'/template_myobjects.odt'; + + if (file_exists($src) && !file_exists($dest)) { + require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + dol_mkdir($dirodt); + $result = dol_copy($src, $dest, '0', 0); + if ($result < 0) { + $langs->load("errors"); + $this->error = $langs->trans('ErrorFailToCopyFile', $src, $dest); + return 0; + } + } + + $sql = array_merge($sql, array( + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")", + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'generic_".strtolower($myTmpObjectKey)."_odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('generic_".strtolower($myTmpObjectKey)."_odt', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")" + )); + } + } + + return $this->_init($sql, $options); + } + + /** + * Migration: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern. + * Idempotent - kann mehrfach ausgeführt werden. + * + * @return void + */ + private function _migrateSocieteSystemUniqueKey() + { + $table = MAIN_DB_PREFIX.'kundenkarte_societe_system'; + + // Prüfen ob Tabelle existiert + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // NULL-Werte in fk_contact auf 0 normalisieren + $this->db->query("UPDATE ".$table." SET fk_contact = 0 WHERE fk_contact IS NULL"); + + // Spalte NOT NULL mit Default 0 setzen + $this->db->query("ALTER TABLE ".$table." MODIFY COLUMN fk_contact integer DEFAULT 0 NOT NULL"); + + // Prüfen ob der UNIQUE KEY fk_contact enthält + $resql = $this->db->query("SHOW INDEX FROM ".$table." WHERE Key_name = 'uk_kundenkarte_societe_system' AND Column_name = 'fk_contact'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; // Bereits migriert + } + + // Alten UNIQUE KEY entfernen (falls vorhanden) + $this->db->query("ALTER TABLE ".$table." DROP INDEX uk_kundenkarte_societe_system"); + + // Neuen UNIQUE KEY mit fk_contact anlegen + $this->db->query("ALTER TABLE ".$table." ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_contact, fk_system)"); + } + + /** + * Run all database migrations. + * Each migration is idempotent - safe to run multiple times. + */ + private function runMigrations() + { + // v3.6.0: Add autocomplete field + $this->migrate_v360_autocomplete(); + + // v3.6.0: Add file pinning + $this->migrate_v360_file_pinning(); + + // v3.6.0: Add tree display mode for fields + $this->migrate_v360_tree_display_mode(); + + // v3.7.0: Add badge color for fields + $this->migrate_v370_badge_color(); + + // v4.1.0: Add fk_building_node for room assignment + $this->migrate_v410_building_node(); + + // v4.1.0: Graph-Positionen speichern + $this->migrate_v410_graph_positions(); + } + + /** + * Migration v3.6.0: Add enable_autocomplete column to type fields + */ + private function migrate_v360_autocomplete() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + + // Check if table exists + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Check if column exists + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'enable_autocomplete'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; // Already migrated + } + + // Add column + $this->db->query("ALTER TABLE ".$table." ADD COLUMN enable_autocomplete tinyint DEFAULT 0 NOT NULL AFTER show_in_hover"); + } + + /** + * Migration v3.6.0: Add is_pinned column to files + */ + private function migrate_v360_file_pinning() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage_files"; + + // Check if table exists + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Check if column exists + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'is_pinned'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; // Already migrated + } + + // Add column + $this->db->query("ALTER TABLE ".$table." ADD COLUMN is_pinned tinyint DEFAULT 0 NOT NULL AFTER is_cover"); + } + + /** + * Migration v3.6.0: Add tree_display_mode column to type fields + */ + private function migrate_v360_tree_display_mode() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + + // Check if table exists + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Check if column exists + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'tree_display_mode'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; // Already migrated + } + + // Add column + $this->db->query("ALTER TABLE ".$table." ADD COLUMN tree_display_mode varchar(20) DEFAULT 'badge' AFTER show_in_tree"); + } + + /** + * Migration v3.7.0: Add badge_color column to type fields + */ + private function migrate_v370_badge_color() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field"; + + // Check if table exists + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Check if column exists + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'badge_color'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; // Already migrated + } + + // Add column + $this->db->query("ALTER TABLE ".$table." ADD COLUMN badge_color varchar(7) AFTER tree_display_mode"); + } + + /** + * Migration v4.1.0: Spalte fk_building_node für Raumzuordnung + * Trennt Gebäude-/Raumzuordnung von technischem Parent (fk_parent) + */ + private function migrate_v410_building_node() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage"; + + // Prüfen ob Tabelle existiert + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Prüfen ob Spalte bereits existiert + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'fk_building_node'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; + } + + // Spalte hinzufügen + $this->db->query("ALTER TABLE ".$table." ADD COLUMN fk_building_node integer DEFAULT 0 AFTER fk_system"); + + // Index für Performance + $this->db->query("ALTER TABLE ".$table." ADD INDEX idx_anlage_building_node (fk_building_node)"); + } + + /** + * Migration v4.1.0: Graph-Positionen (x/y) in Anlage-Tabelle + */ + private function migrate_v410_graph_positions() + { + $table = MAIN_DB_PREFIX."kundenkarte_anlage"; + + // Prüfen ob Tabelle existiert + $resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'"); + if (!$resql || $this->db->num_rows($resql) == 0) { + return; + } + + // Prüfen ob Spalte bereits existiert + $resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'graph_x'"); + if ($resql && $this->db->num_rows($resql) > 0) { + return; + } + + // Spalten hinzufügen + $this->db->query("ALTER TABLE ".$table." ADD COLUMN graph_x double DEFAULT NULL AFTER fk_building_node"); + $this->db->query("ALTER TABLE ".$table." ADD COLUMN graph_y double DEFAULT NULL AFTER graph_x"); + } + + /** + * Function called when module is disabled. + * Remove from database constants, boxes and permissions from Dolibarr database. + * Data directories are not deleted + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/css/kundenkarte.css b/css/kundenkarte.css new file mode 100755 index 0000000..c01dbfb --- /dev/null +++ b/css/kundenkarte.css @@ -0,0 +1,2813 @@ +/** + * KundenKarte Module Styles + * Dark Mode Theme + */ + +/* ======================================== + TREE STRUCTURE - Multiple parallel cable lines + ======================================== */ + +.kundenkarte-tree { + font-family: inherit !important; + padding: 10px 0 !important; +} + +/* Row container - holds cable lines + content */ +.kundenkarte-tree-row { + display: flex !important; + align-items: stretch !important; + min-height: 36px !important; +} + +/* Spacer row between cable groups */ +.kundenkarte-tree-row.spacer-row { + min-height: 12px !important; +} + +/* Cable line column - vertical line placeholder */ +.cable-line { + width: 15px !important; + min-width: 15px !important; + position: relative !important; + flex-shrink: 0 !important; +} + +/* Active vertical line (passes through this row to children below) */ +.cable-line.active::before { + content: '' !important; + position: absolute !important; + left: 6px !important; + top: 0 !important; + width: 2px !important; + height: 100% !important; + background: #555 !important; +} + +/* My cable line on connection row - vertical line + continues down */ +.cable-line.my-line.conn-line::before { + content: '' !important; + position: absolute !important; + left: 6px !important; + top: 0 !important; + width: 2px !important; + height: 100% !important; + background: #8bc34a !important; +} + +/* Horizontal connector from line - width set via CSS variable */ +.cable-line.my-line.conn-line::after { + content: '' !important; + position: absolute !important; + left: 6px !important; + top: 50% !important; + width: var(--h-width, 8px) !important; + height: 2px !important; + background: #8bc34a !important; + z-index: 0 !important; +} + +/* My cable line on node row - vertical line ends at center */ +.cable-line.my-line.node-line::before { + content: '' !important; + position: absolute !important; + left: 6px !important; + top: 0 !important; + width: 2px !important; + height: 50% !important; + background: #8bc34a !important; +} + +/* Horizontal connector to node - width set via CSS variable */ +.cable-line.my-line.node-line::after { + content: '' !important; + position: absolute !important; + left: 6px !important; + top: 50% !important; + width: var(--h-width, 8px) !important; + height: 2px !important; + background: #8bc34a !important; + z-index: 0 !important; +} + +/* Node content container - above horizontal lines */ +.kundenkarte-tree-node-content { + flex: 1 !important; + position: relative !important; + z-index: 1 !important; +} + +/* Connection content - above horizontal lines */ +.kundenkarte-tree-conn-content { + position: relative !important; + z-index: 1 !important; +} + +/* Legacy node styles (for root level) */ +.kundenkarte-tree-node { + position: relative !important; + margin: 2px 0 !important; + padding-left: 30px !important; +} + +/* Horizontaler Strich zum Element */ +.kundenkarte-tree-node::after { + content: '' !important; + position: absolute !important; + left: 8px !important; + top: 18px !important; + width: 22px !important; + height: 2px !important; + background: #555 !important; +} + +/* Senkrechter Strich (durchgehend für alle Geschwister) */ +.kundenkarte-tree-node::before { + content: '' !important; + position: absolute !important; + left: 8px !important; + top: 0 !important; + width: 2px !important; + height: 100% !important; + background: #555 !important; +} + +/* Letztes Kind: senkrechter Strich nur bis zur Mitte */ +.kundenkarte-tree-node:last-child::before { + height: 20px !important; +} + +/* Root-Ebene: keine Linien */ +.kundenkarte-tree > .kundenkarte-tree-node::after, +.kundenkarte-tree > .kundenkarte-tree-node::before { + display: none !important; +} + +.kundenkarte-tree > .kundenkarte-tree-node { + padding-left: 0 !important; +} + +/* Abstand zwischen Root-Elementen (nicht beim ersten) */ +.kundenkarte-tree > .kundenkarte-tree-node + .kundenkarte-tree-node { + margin-top: 12px !important; +} + +/* Tree-row at root level - no lines */ +.kundenkarte-tree > .kundenkarte-tree-row .cable-line { + display: none !important; +} + +/* Durchgeschleifte Elemente (kein eigenes Kabel) - am gleichen senkrechten Strich */ +.kundenkarte-tree-node.no-cable { + /* Kein eigener Strich - nutzt den vorherigen */ +} + +/* Elemente mit eigenem Kabel - eigener senkrechter Strich, eingerückt */ +.kundenkarte-tree-node:not(.no-cable) { + /* Standard-Darstellung mit eigenem Strich */ +} + +.kundenkarte-tree-item { + display: flex !important; + align-items: center !important; + padding: 8px 12px !important; + border-radius: 4px !important; + background: #2d2d2d !important; + border: 1px solid #444 !important; + cursor: pointer !important; + color: #e0e0e0 !important; + position: relative !important; + z-index: 1 !important; +} + +.kundenkarte-tree-item:hover { + background: #3a3a3a !important; + border-color: #555 !important; +} + +.kundenkarte-tree-toggle { + width: 20px !important; + height: 20px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-right: 5px !important; + cursor: pointer !important; + color: #aaa !important; +} + +.kundenkarte-tree-toggle .fa-chevron-down { + transition: transform 0.2s ease !important; +} + +.kundenkarte-tree-toggle.collapsed .fa-chevron-down { + transform: rotate(-90deg) !important; +} + +.kundenkarte-tree-icon { + width: 24px !important; + height: 24px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-right: 8px !important; + font-size: 14px !important; + color: #aaa !important; +} + +.kundenkarte-tree-label { + flex: 1 !important; + font-weight: 500 !important; + color: #e0e0e0 !important; +} + +.kundenkarte-tree-label-info { + font-weight: normal !important; + color: #888 !important; + font-size: 0.85em !important; + margin-left: 4px !important; +} + +/* Spacer to push badges to the right */ +.kundenkarte-tree-spacer { + flex: 1 !important; + min-width: 10px !important; +} + +/* Tree info badges */ +.kundenkarte-tree-badges { + display: inline-flex !important; + gap: 6px !important; + margin-left: 0 !important; + margin-right: 8px !important; + flex-wrap: wrap !important; +} + +.kundenkarte-tree-badge { + display: inline-flex !important; + align-items: center !important; + vertical-align: middle !important; + gap: 4px !important; + padding: 2px 8px !important; + background: linear-gradient(135deg, #2a4a5e 0%, #1e3a4a 100%) !important; + border: 1px solid #3a6a8e !important; + border-radius: 12px !important; + font-size: 11px !important; + color: #8cc4e8 !important; + font-weight: 500 !important; + white-space: nowrap !important; +} + +.kundenkarte-tree-badge i { + font-size: 10px !important; + color: #5aa8d4 !important; +} + +.kundenkarte-tree-badge:hover { + background: linear-gradient(135deg, #3a5a6e 0%, #2a4a5a 100%) !important; + border-color: #4a7a9e !important; +} + +.kundenkarte-tree-actions { + display: flex !important; + gap: 5px !important; + margin-left: 10px !important; + opacity: 0.6 !important; + transition: opacity 0.15s !important; +} + +.kundenkarte-tree-item:hover .kundenkarte-tree-actions { + opacity: 1 !important; +} + +.kundenkarte-tree-actions a { + padding: 2px 6px !important; + border-radius: 3px !important; + color: #ccc !important; +} + +.kundenkarte-tree-actions a:hover { + background: #555 !important; + color: #fff !important; +} + +.kundenkarte-tree-children { + position: relative !important; + margin-left: 20px !important; + padding-left: 0 !important; +} + +.kundenkarte-tree-children.collapsed { + display: none !important; +} + +/* Drag & Drop Sortierung */ +.kundenkarte-dragging { + opacity: 0.4 !important; +} + +.kundenkarte-dragging > .kundenkarte-tree-item { + border: 1px dashed #666 !important; +} + +body.kundenkarte-drag-active { + cursor: grabbing !important; + user-select: none !important; +} + +body.kundenkarte-drag-active * { + cursor: grabbing !important; +} + +.kundenkarte-drag-above > .kundenkarte-tree-item { + border-top: 3px solid #4a9eff !important; +} + +.kundenkarte-drag-below > .kundenkarte-tree-item { + border-bottom: 3px solid #4a9eff !important; +} + +.kundenkarte-tree-type { + font-size: 0.75em !important; + padding: 2px 8px !important; + border-radius: 10px !important; + background: #444 !important; + color: #ccc !important; + margin-left: 8px !important; + font-weight: normal !important; +} + +/* Tree - File Indicators */ +.kundenkarte-tree-files { + display: inline-flex !important; + vertical-align: middle !important; + gap: 3px !important; + margin-left: 6px !important; +} + +.kundenkarte-tree-file-badge { + font-size: 0.8em !important; + padding: 2px 6px !important; + border-radius: 4px !important; + display: inline-flex !important; + align-items: center !important; + vertical-align: middle !important; + gap: 3px !important; + text-decoration: none !important; +} + +.kundenkarte-tree-file-images { + background: #2a5a8a !important; + color: #ddd !important; +} + +.kundenkarte-tree-file-docs { + background: #8a5a2a !important; + color: #ddd !important; +} + +.kundenkarte-tree-file-all { + background: #4a5a6a !important; + color: #ddd !important; +} + +/* ======================================== + TOOLTIP + ======================================== */ + +.kundenkarte-tooltip { + position: absolute !important; + z-index: 1000 !important; + background: #1e1e1e !important; + border: 1px solid #555 !important; + border-radius: 6px !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important; + padding: 15px !important; + min-width: 300px !important; + max-width: 450px !important; + display: none !important; +} + +.kundenkarte-tooltip.visible { + display: block !important; +} + +.kundenkarte-tooltip-header { + display: flex !important; + align-items: center !important; + margin-bottom: 10px !important; + padding-bottom: 10px !important; + border-bottom: 1px solid #444 !important; +} + +.kundenkarte-tooltip-icon { + font-size: 24px !important; + margin-right: 12px !important; + color: #aaa !important; +} + +.kundenkarte-tooltip-title { + font-weight: 600 !important; + font-size: 1.1em !important; + color: #e0e0e0 !important; +} + +.kundenkarte-tooltip-type { + color: #999 !important; + font-size: 0.9em !important; +} + +.kundenkarte-tooltip-fields { + display: grid !important; + grid-template-columns: auto 1fr !important; + gap: 6px 12px !important; + font-size: 0.95em !important; +} + +.kundenkarte-tooltip-field-label { + color: #aaa !important; + font-weight: 500 !important; +} + +.kundenkarte-tooltip-field-value { + color: #e0e0e0 !important; +} + +.kundenkarte-tooltip-field-header { + grid-column: 1 / -1 !important; + font-weight: bold !important; + margin-top: 8px !important; + padding-top: 8px !important; + border-top: 1px solid #444 !important; + color: #aaa !important; +} + +/* ======================================== + FILE PREVIEW TOOLTIP + ======================================== */ + +.kundenkarte-file-preview { + min-width: 280px !important; + max-width: 400px !important; +} + +.kundenkarte-file-preview-section { + margin-bottom: 12px !important; +} + +.kundenkarte-file-preview-section:last-child { + margin-bottom: 0 !important; +} + +.kundenkarte-file-preview-title { + font-weight: bold !important; + color: #8cc4e8 !important; + margin-bottom: 8px !important; + font-size: 12px !important; + display: flex !important; + align-items: center !important; + gap: 6px !important; +} + +.kundenkarte-file-preview-thumbs { + display: flex !important; + flex-wrap: wrap !important; + gap: 6px !important; +} + +.kundenkarte-file-preview-thumb { + position: relative !important; + width: 60px !important; + height: 60px !important; + border-radius: 4px !important; + overflow: hidden !important; + border: 2px solid #444 !important; + transition: border-color 0.2s !important; +} + +.kundenkarte-file-preview-thumb:hover { + border-color: #8cc4e8 !important; +} + +.kundenkarte-file-preview-thumb.is-cover { + border-color: #f39c12 !important; +} + +.kundenkarte-file-preview-thumb.is-pinned { + border-color: #27ae60 !important; +} + +.kundenkarte-file-preview-thumb img { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; +} + +.kundenkarte-file-preview-pin { + position: absolute !important; + top: 2px !important; + right: 2px !important; + background: rgba(39, 174, 96, 0.9) !important; + color: #fff !important; + font-size: 9px !important; + padding: 2px 4px !important; + border-radius: 3px !important; +} + +.kundenkarte-file-preview-more { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 60px !important; + height: 60px !important; + background: #333 !important; + border-radius: 4px !important; + color: #888 !important; + font-size: 14px !important; + font-weight: bold !important; +} + +.kundenkarte-file-preview-docs { + display: flex !important; + flex-direction: column !important; + gap: 4px !important; +} + +.kundenkarte-file-preview-doc { + display: flex !important; + align-items: center !important; + gap: 8px !important; + padding: 6px 8px !important; + background: #2a2a2a !important; + border-radius: 4px !important; + text-decoration: none !important; + color: #ddd !important; + transition: background-color 0.2s !important; +} + +.kundenkarte-file-preview-doc:hover { + background: #3a3a3a !important; + color: #fff !important; +} + +.kundenkarte-file-preview-doc.is-pinned { + background: #2a3a2a !important; +} + +.kundenkarte-file-preview-doc i:first-child { + font-size: 16px !important; + width: 20px !important; + text-align: center !important; +} + +.kundenkarte-file-preview-doc-name { + flex: 1 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + font-size: 12px !important; +} + +.kundenkarte-file-preview-doc-pin { + color: #27ae60 !important; + font-size: 10px !important; +} + +.kundenkarte-file-preview-more-docs { + padding: 4px 8px !important; + color: #888 !important; + font-size: 11px !important; + text-align: center !important; +} + +/* ======================================== + SYSTEM TABS + ======================================== */ + +.kundenkarte-system-tabs-wrapper { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + margin-bottom: 15px !important; + padding-bottom: 10px !important; + border-bottom: 2px solid #444 !important; + flex-wrap: wrap !important; + gap: 10px !important; +} + +.kundenkarte-system-tabs { + display: flex !important; + flex-wrap: wrap !important; + gap: 5px !important; + flex: 1 !important; + align-items: center !important; +} + +.kundenkarte-tree-controls { + display: flex !important; + gap: 5px !important; + flex-shrink: 0 !important; + align-items: center !important; +} + +/* Button in system tabs row */ +.kundenkarte-system-tabs > button.button, +.kundenkarte-system-tabs > .button { + align-self: center !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + vertical-align: middle !important; +} + +/* System hinzufügen Button - kleiner, knapp über der Linie */ +.kundenkarte-add-system-btn { + font-size: 11px !important; + padding: 4px 10px !important; + margin-left: auto !important; + align-self: flex-end !important; + margin-bottom: -1px !important; + height: auto !important; + line-height: 1.3 !important; +} + +.kundenkarte-add-system-btn i { + font-size: 9px !important; +} + +.kundenkarte-system-tab { + padding: 8px 16px !important; + border: 1px solid #555 !important; + border-radius: 4px 4px 0 0 !important; + background: #333 !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + gap: 8px !important; + color: #ccc !important; +} + +.kundenkarte-system-tab:hover { + background: #3a3a3a !important; +} + +.kundenkarte-system-tab.active { + background: #2d2d2d !important; + border-bottom-color: #2d2d2d !important; + margin-bottom: -2px !important; + padding-bottom: 10px !important; + color: #fff !important; +} + +.kundenkarte-system-tab-icon { + font-size: 14px !important; +} + +/* ======================================== + BUTTONS - Dark Mode style + ======================================== */ + +.kundenkarte-tree-controls .button, +.kundenkarte-tree-controls .button.small, +.kundenkarte-tree-controls a.button, +.kundenkarte-system-tabs .button, +.kundenkarte-system-tabs .button.small, +.kundenkarte-system-tabs button.button, +.kundenkarte-add-system-form .button, +.kundenkarte-add-system-form .button.small, +.kundenkarte-add-system-form button.button { + background: #444 !important; + background-image: linear-gradient(to bottom, #555 0%, #333 100%) !important; + border: 1px solid #666 !important; + border-radius: 3px !important; + color: #e0e0e0 !important; + cursor: pointer !important; + padding: 6px 12px !important; + font-size: 0.85em !important; + text-decoration: none !important; + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + height: auto !important; + line-height: 1.4 !important; + box-sizing: border-box !important; +} + +.kundenkarte-tree-controls .button:hover, +.kundenkarte-tree-controls a.button:hover, +.kundenkarte-system-tabs .button:hover, +.kundenkarte-system-tabs button.button:hover, +.kundenkarte-add-system-form .button:hover, +.kundenkarte-add-system-form button.button:hover { + background: #555 !important; + background-image: linear-gradient(to bottom, #666 0%, #444 100%) !important; + border-color: #777 !important; +} + +/* Add system form */ +.kundenkarte-add-system-form { + background: #2d2d2d !important; + border: 1px solid #444 !important; + padding: 12px 15px !important; + border-radius: 6px !important; + color: #e0e0e0 !important; +} + +.kundenkarte-add-system-form select.flat { + background: #333 !important; + border: 1px solid #555 !important; + color: #e0e0e0 !important; + padding: 5px 10px !important; + border-radius: 3px !important; +} + + +/* ======================================== + FAVORITES + ======================================== */ + +.kundenkarte-favorites-add { + display: flex !important; + gap: 10px !important; + margin-bottom: 20px !important; + padding: 15px !important; + background: #2d2d2d !important; + border: 1px solid #444 !important; + border-radius: 6px !important; +} + +.kundenkarte-favorites-table th { + text-align: left !important; + padding: 10px !important; + background: #333 !important; + color: #e0e0e0 !important; + border-bottom: 2px solid #555 !important; +} + +.kundenkarte-favorites-table td { + padding: 10px !important; + border-bottom: 1px solid #444 !important; + vertical-align: middle !important; +} + +.kundenkarte-favorites-actions { + margin-top: 20px !important; + padding-top: 15px !important; + border-top: 1px solid #444 !important; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; +} + +.kundenkarte-favorites-add .button { + background: #444 !important; + background-image: linear-gradient(to bottom, #555 0%, #333 100%) !important; + border: 1px solid #666 !important; + border-radius: 3px !important; + color: #e0e0e0 !important; + padding: 6px 12px !important; +} + +/* ======================================== + FILE GALLERY + ======================================== */ + +/* ======================================== + FILE DROPZONE + ======================================== */ + +.kundenkarte-dropzone { + border: 2px dashed #555 !important; + border-radius: 8px !important; + padding: 30px !important; + text-align: center !important; + background: #1e1e1e !important; + transition: all 0.3s ease !important; + cursor: pointer !important; +} + +.kundenkarte-dropzone:hover, +.kundenkarte-dropzone.dragover { + border-color: #3498db !important; + background: #252530 !important; +} + +.kundenkarte-dropzone.dragover { + transform: scale(1.02) !important; +} + +.kundenkarte-dropzone-content { + pointer-events: none !important; +} + +.kundenkarte-dropzone-files { + display: flex !important; + flex-wrap: wrap !important; + gap: 10px !important; + margin-top: 15px !important; + justify-content: center !important; +} + +.kundenkarte-dropzone-file { + display: flex !important; + align-items: center !important; + gap: 8px !important; + padding: 8px 12px !important; + background: #333 !important; + border-radius: 4px !important; + font-size: 12px !important; + color: #e0e0e0 !important; +} + +.kundenkarte-dropzone-file i { + color: #3498db !important; +} + +.kundenkarte-dropzone-file .remove-file { + color: #e74c3c !important; + cursor: pointer !important; + margin-left: 5px !important; +} + +.kundenkarte-dropzone-file .remove-file:hover { + color: #ff6b6b !important; +} + +/* ======================================== + FILE GRID - LARGER BOXES + ======================================== */ + +.kundenkarte-files-grid { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) !important; + gap: 20px !important; + margin-top: 15px !important; +} + +.kundenkarte-file-item { + border: 1px solid #444 !important; + border-radius: 8px !important; + overflow: hidden !important; + background: #2d2d2d !important; + transition: transform 0.2s, box-shadow 0.2s !important; + position: relative !important; +} + +.kundenkarte-file-item:hover { + transform: translateY(-3px) !important; + box-shadow: 0 6px 20px rgba(0,0,0,0.3) !important; +} + +/* Pinned files */ +.kundenkarte-file-pinned { + border-color: #e6a500 !important; + box-shadow: 0 0 0 1px #e6a500 inset !important; +} + +.kundenkarte-pin-indicator { + position: absolute !important; + top: 8px !important; + right: 8px !important; + background: #e6a500 !important; + color: #000 !important; + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 12px !important; + z-index: 10 !important; +} + +.kundenkarte-file-btn-pinned { + color: #e6a500 !important; +} + +.kundenkarte-file-preview { + height: 180px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + background: #1e1e1e !important; + overflow: hidden !important; + cursor: pointer !important; +} + +.kundenkarte-file-preview img { + max-width: 100% !important; + max-height: 100% !important; + object-fit: contain !important; + transition: transform 0.3s !important; +} + +.kundenkarte-file-item:hover .kundenkarte-file-preview img { + transform: scale(1.05) !important; +} + +.kundenkarte-file-info { + padding: 12px 15px !important; + font-size: 0.95em !important; +} + +.kundenkarte-file-name { + font-weight: 500 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + color: #e0e0e0 !important; + font-size: 13px !important; + margin-bottom: 4px !important; +} + +.kundenkarte-file-size { + color: #888 !important; + font-size: 11px !important; +} + +.kundenkarte-file-actions { + display: flex !important; + gap: 8px !important; + margin-top: 10px !important; + justify-content: center !important; +} + +.kundenkarte-file-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 32px !important; + height: 32px !important; + border-radius: 6px !important; + background: #444 !important; + color: #e0e0e0 !important; + text-decoration: none !important; + transition: all 0.2s !important; +} + +.kundenkarte-file-btn:hover { + background: #555 !important; + color: #fff !important; + transform: scale(1.1) !important; +} + +.kundenkarte-file-btn-delete:hover { + background: #c0392b !important; +} + +/* ======================================== + ICON PICKER MODAL + ======================================== */ + +.kundenkarte-modal { + display: none !important; + position: fixed !important; + z-index: 10000 !important; + left: 0 !important; + top: 0 !important; + width: 100% !important; + height: 100% !important; + background-color: rgba(0,0,0,0.7) !important; + align-items: center !important; + justify-content: center !important; +} + +.kundenkarte-modal.visible { + display: flex !important; +} + +.kundenkarte-modal-content { + background-color: #1e1e1e !important; + border-radius: 8px !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important; + max-width: 600px !important; + width: 90% !important; + max-height: 80vh !important; + display: flex !important; + flex-direction: column !important; +} + +.kundenkarte-modal-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 15px 20px !important; + border-bottom: 2px solid #444 !important; + background: #2d2d2d !important; +} + +.kundenkarte-modal-header h3 { + margin: 0 !important; + color: #e0e0e0 !important; +} + +.kundenkarte-modal-close { + font-size: 28px !important; + color: #aaa !important; + cursor: pointer !important; +} + +.kundenkarte-modal-body { + padding: 20px !important; + overflow-y: auto !important; +} + +.kundenkarte-icon-grid { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)) !important; + gap: 8px !important; + max-height: 400px !important; + overflow-y: auto !important; +} + +.kundenkarte-icon-item { + width: 50px !important; + height: 50px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + border: 1px solid #444 !important; + border-radius: 6px !important; + cursor: pointer !important; + font-size: 20px !important; + color: #aaa !important; + background: #2d2d2d !important; +} + +.kundenkarte-icon-item:hover { + background: #444 !important; + border-color: #666 !important; + color: #fff !important; +} + +/* ======================================== + PDF PREVIEW + ======================================== */ + +.kundenkarte-pdf-preview-wrapper { + width: 100% !important; + height: 100% !important; + overflow: hidden !important; + position: relative !important; +} + +.kundenkarte-pdf-preview-frame { + width: 200% !important; + height: 200% !important; + border: none !important; + transform: scale(0.5) !important; + transform-origin: top left !important; + pointer-events: none !important; +} + +/* ======================================== + IMAGES/DOCS POPUP + ======================================== */ + +.kundenkarte-images-grid, +.kundenkarte-docs-grid { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)) !important; + gap: 8px !important; + max-width: 350px !important; +} + +.kundenkarte-images-thumb { + display: block !important; + width: 80px !important; + height: 80px !important; + border-radius: 4px !important; + overflow: hidden !important; + border: 2px solid #444 !important; +} + +.kundenkarte-images-thumb img { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; +} + +.kundenkarte-docs-card { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + padding: 10px 5px !important; + border-radius: 6px !important; + border: 1px solid #444 !important; + background: #2d2d2d !important; + text-decoration: none !important; + width: 80px !important; +} + +.kundenkarte-docs-card-icon { + font-size: 36px !important; + margin-bottom: 6px !important; + color: #aaa !important; +} + +.kundenkarte-docs-card-name { + font-size: 0.75em !important; + color: #e0e0e0 !important; + text-align: center !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + max-width: 70px !important; +} + +/* ======================================== + EQUIPMENT / HUTSCHIENEN + ======================================== */ + +.kundenkarte-equipment-container { + margin-top: 15px !important; + padding: 15px !important; + background: #252525 !important; + border: 1px solid #444 !important; + border-radius: 6px !important; + overflow: visible !important; +} + +.kundenkarte-equipment-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + margin-bottom: 15px !important; + padding-bottom: 10px !important; + border-bottom: 1px solid #444 !important; +} + +.kundenkarte-equipment-header h4 { + margin: 0 !important; + color: #e0e0e0 !important; + font-size: 1em !important; +} + +.kundenkarte-add-carrier { + background: #2d6a4f !important; + color: #fff !important; + border: 1px solid #40916c !important; + padding: 6px 12px !important; + border-radius: 4px !important; + cursor: pointer !important; + font-size: 0.85em !important; + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + text-decoration: none !important; +} + +.kundenkarte-add-carrier:hover { + background: #40916c !important; +} + +/* Panels Container */ +.kundenkarte-panels-container { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + gap: 20px !important; + padding: 10px 5px !important; +} + +/* Individual Panel (Feld) */ +.kundenkarte-panel { + flex: 0 0 auto !important; + background: #1e1e1e !important; + border: 2px solid #555 !important; + border-radius: 8px !important; + padding: 10px !important; + display: inline-block !important; +} + +.kundenkarte-panel-direct { + border-style: dashed !important; + border-color: #666 !important; +} + +/* Quick-add panel button (duplicate last panel) */ +.kundenkarte-panel-quickadd { + flex: 0 0 auto !important; + min-width: 60px !important; + max-width: 80px !important; + background: transparent !important; + border: 2px dashed #555 !important; + border-radius: 8px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + min-height: 100px !important; +} + +.kundenkarte-panel-quickadd:hover { + border-color: #27ae60 !important; + background: rgba(39, 174, 96, 0.1) !important; +} + +.kundenkarte-panel-quickadd i { + font-size: 24px !important; + color: #555 !important; + transition: color 0.2s ease !important; +} + +.kundenkarte-panel-quickadd:hover i { + color: #27ae60 !important; +} + +/* Quick-add carrier button (duplicate last carrier) */ +.kundenkarte-carrier-quickadd { + background: transparent !important; + border: 2px dashed #555 !important; + border-radius: 6px !important; + padding: 15px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + margin-top: 10px !important; +} + +.kundenkarte-carrier-quickadd:hover { + border-color: #27ae60 !important; + background: rgba(39, 174, 96, 0.1) !important; +} + +.kundenkarte-carrier-quickadd i { + font-size: 18px !important; + color: #555 !important; + transition: color 0.2s ease !important; +} + +.kundenkarte-carrier-quickadd:hover i { + color: #27ae60 !important; +} + +.kundenkarte-panel-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 8px 12px !important; + background: #333 !important; + border-radius: 4px !important; + margin-bottom: 10px !important; +} + +.kundenkarte-panel-label { + font-weight: bold !important; + color: #e0e0e0 !important; + font-size: 0.95em !important; +} + +.kundenkarte-panel-actions { + display: flex !important; + gap: 8px !important; +} + +.kundenkarte-panel-actions a { + color: #aaa !important; + text-decoration: none !important; + padding: 4px !important; +} + +.kundenkarte-panel-actions a:hover { + color: #fff !important; +} + +/* Add Panel Button */ +.kundenkarte-add-panel { + background: #1e5f74 !important; + color: #fff !important; + border: 1px solid #2c8ba0 !important; + padding: 6px 12px !important; + border-radius: 4px !important; + cursor: pointer !important; + font-size: 0.85em !important; + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + text-decoration: none !important; +} + +.kundenkarte-add-panel:hover { + background: #2c8ba0 !important; +} + +.kundenkarte-carriers-list { + display: flex !important; + flex-direction: column !important; + gap: 20px !important; + overflow: visible !important; +} + +/* Individual Carrier (Hutschiene) */ +.kundenkarte-carrier { + background: #2d2d2d !important; + border: 1px solid #444 !important; + border-radius: 6px !important; + overflow: visible !important; + display: block !important; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.kundenkarte-carrier-header { + display: flex !important; + align-items: center !important; + padding: 10px 15px !important; + background: #333 !important; + border-bottom: 1px solid #444 !important; +} + +.kundenkarte-carrier-label { + font-weight: 600 !important; + color: #e0e0e0 !important; + flex: 1 !important; +} + +.kundenkarte-carrier-info { + color: #888 !important; + font-size: 0.85em !important; + margin-right: 15px !important; +} + +.kundenkarte-carrier-actions { + display: flex !important; + gap: 8px !important; +} + +.kundenkarte-carrier-actions a { + color: #888 !important; + padding: 4px 8px !important; + border-radius: 3px !important; + text-decoration: none !important; +} + +.kundenkarte-carrier-actions a:hover { + background: #444 !important; + color: #fff !important; +} + +.kundenkarte-carrier-add-equipment { + color: #27ae60 !important; +} + +.kundenkarte-carrier-add-equipment:hover { + background: #27ae60 !important; + color: #fff !important; +} + +/* SVG Container */ +.kundenkarte-carrier-svg-container { + position: relative !important; + padding: 15px !important; + background: #1e1e1e !important; + overflow-x: auto !important; + overflow-y: visible !important; + border-radius: 0 0 6px 6px !important; + display: block !important; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.kundenkarte-carrier-svg { + display: block !important; + background: linear-gradient(to bottom, #3a3a3a 0%, #2a2a2a 100%) !important; + border-radius: 4px !important; + border: 1px solid #555 !important; + min-width: max-content !important; + flex-shrink: 0 !important; +} + +/* Clickable slot overlay */ +.kundenkarte-carrier-slots { + position: absolute !important; + top: 15px !important; + left: 15px !important; + pointer-events: none !important; +} + +.kundenkarte-slot-empty { + position: absolute !important; + top: 0 !important; + height: 100% !important; + pointer-events: all !important; + cursor: pointer !important; + opacity: 0 !important; + transition: opacity 0.15s, background 0.15s !important; +} + +.kundenkarte-slot-empty:hover { + opacity: 1 !important; + background: rgba(39, 174, 96, 0.3) !important; + border: 1px dashed #27ae60 !important; + border-radius: 3px !important; +} + +.kundenkarte-slot-drop-target { + opacity: 1 !important; + background: rgba(52, 152, 219, 0.4) !important; + border: 2px dashed #3498db !important; + border-radius: 3px !important; +} + +/* Quick-add slot (duplicate last equipment) */ +.kundenkarte-slot-quickadd { + position: absolute !important; + top: 2px !important; + height: calc(100% - 4px) !important; + pointer-events: all !important; + cursor: pointer !important; + background: rgba(39, 174, 96, 0.15) !important; + border: 2px dashed #27ae60 !important; + border-radius: 4px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: all 0.2s ease !important; +} + +.kundenkarte-slot-quickadd i { + color: #27ae60 !important; + font-size: 24px !important; +} + +.kundenkarte-slot-quickadd:hover { + background: rgba(39, 174, 96, 0.4) !important; + border-color: #1e8449 !important; +} + +.kundenkarte-slot-quickadd:hover i { + color: #fff !important; +} + +/* Equipment Block (SVG elements styled via CSS) */ +.kundenkarte-equipment-block { + cursor: pointer !important; + transition: filter 0.15s !important; +} + +.kundenkarte-equipment-block:hover { + filter: brightness(1.15) !important; +} + +.kundenkarte-equipment-block.dragging { + opacity: 0.5 !important; +} + +/* Duplicate button on blocks */ +.kundenkarte-equipment-duplicate { + cursor: pointer !important; + opacity: 0 !important; + transition: opacity 0.15s !important; +} + +.kundenkarte-equipment-block:hover .kundenkarte-equipment-duplicate { + opacity: 1 !important; +} + +/* Equipment Tooltip */ +.kundenkarte-equipment-tooltip { + position: absolute !important; + z-index: 10001 !important; + background: #1e1e1e !important; + border: 1px solid #555 !important; + border-radius: 6px !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important; + padding: 12px !important; + min-width: 180px !important; + max-width: 280px !important; + display: none !important; + pointer-events: none !important; +} + +.kundenkarte-equipment-tooltip.visible { + display: block !important; +} + +.kundenkarte-equipment-tooltip-header { + margin-bottom: 8px !important; + padding-bottom: 8px !important; + border-bottom: 1px solid #444 !important; + color: #e0e0e0 !important; +} + +.kundenkarte-equipment-tooltip-fields { + font-size: 0.9em !important; +} + +.kundenkarte-equipment-tooltip-fields .field-label { + color: #888 !important; +} + +.kundenkarte-equipment-tooltip-fields .field-value { + color: #e0e0e0 !important; +} + +/* Modal Footer */ +.kundenkarte-modal-footer { + padding: 15px 20px !important; + border-top: 1px solid #444 !important; + background: #2d2d2d !important; + text-align: right !important; + border-radius: 0 0 8px 8px !important; +} + +.kundenkarte-modal-footer .button { + margin-left: 10px !important; +} + +/* Equipment color swatches in admin */ +.kundenkarte-color-swatch { + display: inline-block !important; + width: 24px !important; + height: 24px !important; + border-radius: 4px !important; + border: 1px solid #666 !important; + vertical-align: middle !important; + margin-right: 8px !important; +} + +/* Equipment Type Badge */ +.kundenkarte-equipment-type-badge { + display: inline-flex !important; + align-items: center !important; + gap: 6px !important; + padding: 4px 10px !important; + border-radius: 4px !important; + font-size: 0.85em !important; + background: #333 !important; + border: 1px solid #444 !important; + color: #e0e0e0 !important; +} + +.kundenkarte-equipment-type-badge .te-width { + color: #888 !important; + font-size: 0.9em !important; +} + +/* Responsive: scroll on small screens */ +@media (max-width: 768px) { + .kundenkarte-carrier-svg-container { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + } +} + +/* ======================================== + CONNECTIONS (Stromverbindungen) + ======================================== */ + +.kundenkarte-connections-container { + margin-top: 5px !important; + padding: 8px 15px !important; + background: #252525 !important; + border-top: 1px solid #333 !important; +} + +.kundenkarte-connections-svg { + display: block !important; + width: 100% !important; +} + +/* Connection lines */ +.kundenkarte-connection-line { + fill: none !important; + stroke-width: 2 !important; + stroke-linecap: round !important; +} + +.kundenkarte-connection-line.phase-L1 { stroke: #8B4513 !important; } +.kundenkarte-connection-line.phase-L2 { stroke: #000000 !important; } +.kundenkarte-connection-line.phase-L3 { stroke: #808080 !important; } +.kundenkarte-connection-line.phase-N { stroke: #0000FF !important; } +.kundenkarte-connection-line.phase-PE { stroke: #9ACD32 !important; } +.kundenkarte-connection-line.phase-L1N { stroke: #8B4513 !important; stroke-width: 3 !important; } +.kundenkarte-connection-line.phase-L1L2L3 { stroke: #8B4513 !important; stroke-width: 4 !important; } +.kundenkarte-connection-line.phase-L1L2L3N { stroke: #8B4513 !important; stroke-width: 5 !important; } +.kundenkarte-connection-line.phase-L1L2L3NPE { stroke: #8B4513 !important; stroke-width: 6 !important; } + +/* Rail/Sammelschiene (generic for all systems) */ +.kundenkarte-rail { + cursor: pointer !important; + transition: opacity 0.2s !important; +} + +.kundenkarte-rail-line { + stroke-width: 4 !important; + stroke-linecap: round !important; + transition: stroke-width 0.2s, filter 0.2s !important; +} + +.kundenkarte-rail:hover .kundenkarte-rail-line { + filter: brightness(1.3) drop-shadow(0 0 3px rgba(255,255,255,0.3)) !important; + stroke-width: 6 !important; +} + +.kundenkarte-rail:hover text { + fill: #fff !important; +} + +.kundenkarte-rail text { + font-weight: bold !important; + transition: fill 0.2s !important; +} + +/* Consumer output (Abgang) */ +.kundenkarte-output { + cursor: pointer !important; + transition: opacity 0.2s !important; +} + +.kundenkarte-output-line { + fill: none !important; + stroke-width: 2 !important; + stroke-linecap: round !important; + stroke-dasharray: 5, 3 !important; + transition: stroke-width 0.2s !important; +} + +.kundenkarte-output-label { + font-size: 10px !important; + fill: #aaa !important; + transition: fill 0.2s, font-weight 0.2s !important; +} + +.kundenkarte-output:hover .kundenkarte-output-label { + fill: #fff !important; + font-weight: bold !important; +} + +.kundenkarte-output:hover .kundenkarte-output-line { + stroke-width: 3 !important; + filter: drop-shadow(0 0 2px rgba(255,255,255,0.3)) !important; +} + +/* Add connection button */ +.kundenkarte-add-connection { + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + padding: 5px 10px !important; + margin-top: 8px !important; + background: #333 !important; + border: 1px dashed #555 !important; + border-radius: 4px !important; + color: #888 !important; + font-size: 0.8em !important; + cursor: pointer !important; + text-decoration: none !important; +} + +.kundenkarte-add-connection:hover { + background: #444 !important; + border-color: #27ae60 !important; + color: #27ae60 !important; +} + +/* Connection legend */ +.kundenkarte-connection-legend { + display: flex !important; + flex-wrap: wrap !important; + gap: 10px !important; + margin-top: 8px !important; + padding-top: 8px !important; + border-top: 1px solid #333 !important; + font-size: 0.75em !important; +} + +.kundenkarte-legend-item { + display: flex !important; + align-items: center !important; + gap: 4px !important; + color: #888 !important; +} + +.kundenkarte-legend-color { + width: 20px !important; + height: 4px !important; + border-radius: 2px !important; +} + +.kundenkarte-legend-color.phase-L1 { background: #8B4513 !important; } +.kundenkarte-legend-color.phase-L2 { background: #000000 !important; border: 1px solid #444 !important; } +.kundenkarte-legend-color.phase-L3 { background: #808080 !important; } +.kundenkarte-legend-color.phase-N { background: #0000FF !important; } +.kundenkarte-legend-color.phase-PE { background: #9ACD32 !important; } + +/* Connection dialog form */ +.kundenkarte-connection-form .form-row { + display: flex !important; + gap: 10px !important; + margin-bottom: 12px !important; +} + +.kundenkarte-connection-form .form-group { + flex: 1 !important; +} + +.kundenkarte-connection-form label { + display: block !important; + margin-bottom: 4px !important; + color: #aaa !important; + font-size: 0.85em !important; +} + +.kundenkarte-connection-form select, +.kundenkarte-connection-form input { + width: 100% !important; + padding: 8px 10px !important; + background: #2d2d2d !important; + border: 1px solid #444 !important; + border-radius: 4px !important; + color: #e0e0e0 !important; +} + +.kundenkarte-connection-form select:focus, +.kundenkarte-connection-form input:focus { + border-color: #3498db !important; + outline: none !important; +} + +/* Phase selection buttons */ +.kundenkarte-phase-buttons { + display: flex !important; + flex-wrap: wrap !important; + gap: 5px !important; +} + +.kundenkarte-phase-btn { + padding: 6px 12px !important; + border: 1px solid #444 !important; + border-radius: 4px !important; + background: #2d2d2d !important; + color: #aaa !important; + cursor: pointer !important; + font-size: 0.85em !important; +} + +.kundenkarte-phase-btn:hover { + border-color: #666 !important; + color: #fff !important; +} + +.kundenkarte-phase-btn.active { + border-color: #3498db !important; + background: #3498db !important; + color: #fff !important; +} + +.kundenkarte-phase-btn.phase-L1 { border-left: 3px solid #8B4513 !important; } +.kundenkarte-phase-btn.phase-L2 { border-left: 3px solid #333 !important; } +.kundenkarte-phase-btn.phase-L3 { border-left: 3px solid #808080 !important; } +.kundenkarte-phase-btn.phase-N { border-left: 3px solid #0000FF !important; } +.kundenkarte-phase-btn.phase-PE { border-left: 3px solid #9ACD32 !important; } + +/* ======================================== + jsPlumb Interactive Editor (Prototype) + ======================================== */ + +.kundenkarte-jsplumb-prototype { + margin-top: 20px !important; +} + +.kundenkarte-jsplumb-prototype .titre { + background: #2d2d2d !important; + padding: 10px 15px !important; + border-radius: 6px !important; + color: #ddd !important; +} + +.kundenkarte-jsplumb-prototype .titre:hover { + background: #3d3d3d !important; +} + +#jsplumb-canvas { + position: relative !important; + background: linear-gradient(135deg, #1a1a1a 0%, #252525 100%) !important; +} + +.jsplumb-node { + transition: box-shadow 0.2s ease !important; + z-index: 10 !important; +} + +.jsplumb-node:hover { + z-index: 20 !important; + transform: scale(1.02); +} + +.jsplumb-node.jsplumb-external { + background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%) !important; +} + +/* jsPlumb endpoint styling */ +.jtk-endpoint { + z-index: 30 !important; +} + +.jtk-connector { + z-index: 5 !important; +} + +/* Connection hover effect */ +.jtk-connector:hover path { + stroke-width: 3px !important; +} + +/* ======================================== + CONNECTION EDITOR (Interactive SVG) + ======================================== */ + +.kundenkarte-connection-editor-wrapper { + border-top: 1px solid #333 !important; + background: #1a1a1a !important; +} + +.kundenkarte-connection-editor-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 8px 15px !important; + background: #252525 !important; + border-bottom: 1px solid #333 !important; +} + +.kundenkarte-connection-editor-toggle { + display: inline-flex !important; + align-items: center !important; + gap: 8px !important; + color: #888 !important; + text-decoration: none !important; + font-size: 0.85em !important; + padding: 4px 8px !important; + border-radius: 4px !important; + transition: all 0.2s ease !important; +} + +.kundenkarte-connection-editor-toggle:hover { + color: #fff !important; + background: #333 !important; +} + +.kundenkarte-connection-editor-toggle i { + transition: transform 0.3s ease !important; +} + +.kundenkarte-connection-editor-actions { + display: flex !important; + gap: 8px !important; +} + +.kundenkarte-connection-editor-actions button { + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + padding: 5px 10px !important; + background: #333 !important; + border: 1px solid #444 !important; + border-radius: 4px !important; + color: #aaa !important; + font-size: 0.8em !important; + cursor: pointer !important; + transition: all 0.2s ease !important; +} + +.kundenkarte-connection-editor-actions button:hover { + background: #444 !important; + border-color: #555 !important; + color: #fff !important; +} + +.kundenkarte-add-busbar-btn:hover { + border-color: #3498db !important; + color: #3498db !important; +} + +.kundenkarte-add-conn-btn:hover { + border-color: #27ae60 !important; + color: #27ae60 !important; +} + +.kundenkarte-connection-editor { + display: none !important; + padding: 15px !important; + background: linear-gradient(135deg, #1a1a1a 0%, #202020 100%) !important; + overflow-x: auto !important; +} + +.kundenkarte-connection-editor.expanded { + display: block !important; +} + +.kundenkarte-connection-svg { + display: block !important; + max-width: 100% !important; + border-radius: 4px !important; +} + +/* Endpoints (connection points) */ +.kundenkarte-endpoint { + cursor: crosshair !important; + transition: all 0.2s ease !important; +} + +.kundenkarte-endpoint:hover, +.kundenkarte-endpoint.hover { + r: 8 !important; + filter: drop-shadow(0 0 4px rgba(52, 152, 219, 0.8)) !important; +} + +.kundenkarte-endpoint.dragging { + fill: #3498db !important; + r: 10 !important; + filter: drop-shadow(0 0 6px rgba(52, 152, 219, 1)) !important; +} + +/* Busbar elements */ +.kundenkarte-busbar-element { + cursor: pointer !important; +} + +.kundenkarte-busbar-element:hover .kundenkarte-busbar-line { + filter: brightness(1.2) !important; +} + +.kundenkarte-busbar-line { + transition: filter 0.2s ease !important; +} + +.kundenkarte-busbar-endpoint { + opacity: 0.6 !important; + transition: opacity 0.2s ease, r 0.2s ease !important; +} + +.kundenkarte-busbar-element:hover .kundenkarte-busbar-endpoint, +.kundenkarte-busbar-endpoint:hover { + opacity: 1 !important; +} + +/* Equipment endpoints in editor */ +.kundenkarte-equipment-endpoints rect { + transition: filter 0.2s ease !important; +} + +.kundenkarte-equipment-endpoints:hover rect { + filter: brightness(1.15) !important; +} + +.kundenkarte-equipment-input, +.kundenkarte-equipment-output { + opacity: 0.7 !important; + transition: all 0.2s ease !important; +} + +.kundenkarte-equipment-endpoints:hover .kundenkarte-equipment-input, +.kundenkarte-equipment-endpoints:hover .kundenkarte-equipment-output, +.kundenkarte-equipment-input:hover, +.kundenkarte-equipment-output:hover { + opacity: 1 !important; +} + +/* Connection paths */ +.kundenkarte-connection-path { + cursor: pointer !important; + transition: stroke-width 0.2s ease, filter 0.2s ease !important; +} + +.kundenkarte-connection-path:hover { + stroke-width: 4 !important; + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5)) !important; +} + +.kundenkarte-connection-path-shadow { + pointer-events: none !important; +} + +.kundenkarte-connection-group text { + pointer-events: none !important; + transition: fill 0.2s ease !important; +} + +.kundenkarte-connection-group:hover text { + fill: #fff !important; +} + +/* Connection layer ordering - SVG elements rendered in order */ +.kundenkarte-connections-layer { + pointer-events: auto !important; +} + +.kundenkarte-equipment-layer { + pointer-events: auto !important; +} + +/* Make connection paths clickable with wider hit area */ +.kundenkarte-connection-group { + cursor: pointer !important; +} + +.kundenkarte-connection-group:hover .kundenkarte-connection-path { + stroke-width: 4 !important; +} + +.kundenkarte-connection-group:hover .kundenkarte-connection-path-shadow { + stroke-width: 7 !important; +} + +/* Drag preview line */ +.kundenkarte-drag-preview { + pointer-events: none !important; + animation: dashOffset 0.5s linear infinite !important; +} + +@keyframes dashOffset { + 0% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: 10; } +} + +/* Phase-specific colors for endpoints */ +.kundenkarte-endpoint[data-phase="L1"] { stroke: #8B4513 !important; } +.kundenkarte-endpoint[data-phase="L2"] { stroke: #000000 !important; } +.kundenkarte-endpoint[data-phase="L3"] { stroke: #808080 !important; } +.kundenkarte-endpoint[data-phase="N"] { stroke: #0066cc !important; } +.kundenkarte-endpoint[data-phase="PE"] { stroke: #27ae60 !important; } + +/* Busbar preset buttons */ +.busbar-preset-btn { + transition: transform 0.15s ease, box-shadow 0.15s ease !important; +} + +.busbar-preset-btn:hover { + transform: scale(1.05) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +.busbar-preset-btn:active { + transform: scale(0.98) !important; +} + +/* Connection editor scrollbar */ +.kundenkarte-connection-editor::-webkit-scrollbar { + height: 8px !important; +} + +.kundenkarte-connection-editor::-webkit-scrollbar-track { + background: #1a1a1a !important; + border-radius: 4px !important; +} + +.kundenkarte-connection-editor::-webkit-scrollbar-thumb { + background: #444 !important; + border-radius: 4px !important; +} + +.kundenkarte-connection-editor::-webkit-scrollbar-thumb:hover { + background: #555 !important; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .kundenkarte-connection-editor-header { + flex-direction: column !important; + gap: 10px !important; + align-items: flex-start !important; + } + + .kundenkarte-connection-editor-actions { + flex-wrap: wrap !important; + } + + .kundenkarte-connection-editor-actions button { + flex: 1 !important; + justify-content: center !important; + } +} + +/* ======================================== + SCHEMATIC EDITOR v2 (Interactive SVG) + ======================================== */ + +.schematic-editor-wrapper { + margin-top: 20px !important; + max-width: 100% !important; + overflow-x: auto !important; +} + +/* Prevent Schematic Editor from breaking Dolibarr layout */ +.kundenkarte-equipment-container { + max-width: 100% !important; + overflow-x: auto !important; +} + +.schematic-editor-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 10px 15px !important; + background: #252525 !important; + border: 1px solid #333 !important; + border-radius: 4px 4px 0 0 !important; +} + +.schematic-editor-toggle { + color: #3498db !important; + text-decoration: none !important; + transition: color 0.2s ease !important; +} + +.schematic-editor-toggle:hover { + color: #5dade2 !important; +} + +.schematic-editor-toggle i { + transition: transform 0.3s ease !important; + margin-right: 8px !important; +} + +.schematic-editor-canvas { + display: none !important; + background: #1a1a1a !important; + border: 1px solid #333 !important; + border-top: none !important; + border-radius: 0 0 4px 4px !important; + padding: 15px !important; + overflow: auto !important; + min-height: 300px !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.schematic-editor-canvas.expanded { + display: block !important; +} + +/* SVG Canvas */ +.schematic-svg { + background: #1e1e1e !important; + border-radius: 4px !important; +} + +/* Rails (Hutschienen) */ +.schematic-rail { + cursor: pointer !important; + transition: filter 0.2s ease !important; +} + +.schematic-rail:hover { + filter: brightness(1.3) !important; +} + +.schematic-rail.drop-target { + filter: brightness(1.5) !important; + stroke: #27ae60 !important; + stroke-width: 3px !important; +} + +.schematic-rail-bg { + opacity: 0.8 !important; +} + +/* Equipment Blocks */ +.schematic-block { + cursor: grab !important; + transition: filter 0.2s ease !important; +} + +.schematic-block:hover { + filter: brightness(1.1) !important; +} + +.schematic-block.dragging { + cursor: grabbing !important; + filter: brightness(1.2) drop-shadow(0 0 8px rgba(52, 152, 219, 0.6)) !important; +} + +.schematic-block-bg { + transition: filter 0.2s ease !important; +} + +/* Terminals */ +.schematic-terminal { + cursor: crosshair !important; +} + +.schematic-terminal-circle { + transition: all 0.2s ease !important; +} + +.schematic-terminal:hover .schematic-terminal-circle { + r: 8 !important; + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5)) !important; +} + +/* Connections */ +.schematic-connection { + cursor: pointer !important; + transition: stroke-width 0.2s ease !important; +} + +.schematic-connection:hover { + stroke-width: 4 !important; + filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.4)) !important; +} + +.schematic-connection-shadow { + pointer-events: none !important; +} + +.schematic-connection-group:hover .schematic-connection { + stroke-width: 4 !important; +} + +.schematic-connection-group:hover .schematic-connection-shadow { + stroke-width: 7 !important; +} + +/* Connection Preview */ +.schematic-connection-preview { + pointer-events: none !important; + animation: dashOffset 0.5s linear infinite !important; +} + +/* Messages - Fixed height status bar */ +.schematic-message { + padding: 6px 15px !important; + margin-bottom: 10px !important; + border-radius: 4px !important; + font-size: 12px !important; + height: 28px !important; + min-height: 28px !important; + max-height: 28px !important; + line-height: 16px !important; + overflow: hidden !important; + box-sizing: border-box !important; +} + +.schematic-message.info { + background: #2d4a5e !important; + color: #7ec8e3 !important; + border: 1px solid #3498db !important; +} + +.schematic-message.success { + background: #2d5a3d !important; + color: #7ee8a0 !important; + border: 1px solid #27ae60 !important; +} + +.schematic-message.warning { + background: #5a5a2d !important; + color: #e8e87e !important; + border: 1px solid #f1c40f !important; +} + +.schematic-message.error { + background: #5a2d2d !important; + color: #e87e7e !important; + border: 1px solid #e74c3c !important; +} + +/* Terminal color by phase */ +.schematic-terminal[data-phase="L1"] .schematic-terminal-circle, +.schematic-terminal-circle[data-phase="L1"] { + fill: #8B4513 !important; +} + +.schematic-terminal[data-phase="L2"] .schematic-terminal-circle, +.schematic-terminal-circle[data-phase="L2"] { + fill: #1a1a1a !important; + stroke: #666 !important; +} + +.schematic-terminal[data-phase="L3"] .schematic-terminal-circle, +.schematic-terminal-circle[data-phase="L3"] { + fill: #666666 !important; +} + +.schematic-terminal[data-phase="N"] .schematic-terminal-circle, +.schematic-terminal-circle[data-phase="N"] { + fill: #0066cc !important; +} + +.schematic-terminal[data-phase="PE"] .schematic-terminal-circle, +.schematic-terminal-circle[data-phase="PE"] { + fill: #27ae60 !important; +} + +/* Scrollbar for canvas */ +.schematic-editor-canvas::-webkit-scrollbar { + width: 10px !important; + height: 10px !important; +} + +.schematic-editor-canvas::-webkit-scrollbar-track { + background: #1a1a1a !important; + border-radius: 5px !important; +} + +.schematic-editor-canvas::-webkit-scrollbar-thumb { + background: #444 !important; + border-radius: 5px !important; +} + +.schematic-editor-canvas::-webkit-scrollbar-thumb:hover { + background: #555 !important; +} + +/* ======================================== + ANLAGE CONNECTIONS IN TREE VIEW + Simple cable connection display + ======================================== */ + +/* Cable connection content (link inside row) */ +.kundenkarte-tree-conn-content { + display: flex !important; + align-items: center !important; + gap: 6px !important; + flex: 1 !important; + padding: 4px 12px !important; + background: rgba(139, 195, 74, 0.1) !important; + border: 1px dashed #555 !important; + border-radius: 4px !important; + font-size: 12px !important; + cursor: pointer !important; + transition: all 0.15s ease !important; + text-decoration: none !important; +} + +.kundenkarte-tree-conn-content:hover { + background: rgba(85, 85, 85, 0.25) !important; +} + +/* Legacy conn styles (for root level - used by printTree) */ +.kundenkarte-tree-conn { + display: flex !important; + align-items: center !important; + gap: 6px !important; + padding: 4px 12px 4px 40px !important; + margin: 2px 0 !important; + background: rgba(139, 195, 74, 0.1) !important; + border: 1px dashed #555 !important; + border-radius: 4px !important; + font-size: 12px !important; + cursor: pointer !important; + transition: all 0.15s ease !important; + text-decoration: none !important; + position: relative !important; +} + +/* Senkrechter Strich durch die Kabelzeile (links, durchgehend nach unten) */ +.kundenkarte-tree-conn::before { + content: '' !important; + position: absolute !important; + left: 8px !important; + top: 0 !important; + width: 2px !important; + height: 100% !important; + background: #555 !important; +} + +/* Horizontaler Strich zur Kabelzeile */ +.kundenkarte-tree-conn::after { + content: '' !important; + position: absolute !important; + left: 8px !important; + top: 50% !important; + width: 24px !important; + height: 2px !important; + background: #555 !important; +} + + +.kundenkarte-tree-conn:hover { + background: rgba(85, 85, 85, 0.25) !important; +} + +.kundenkarte-tree-conn .conn-icon, +.kundenkarte-tree-conn-content .conn-icon { + color: #888 !important; + font-size: 11px !important; +} + +.kundenkarte-tree-conn .conn-main, +.kundenkarte-tree-conn-content .conn-main { + color: #e0e0e0 !important; + font-weight: 500 !important; +} + +.kundenkarte-tree-conn .conn-label, +.kundenkarte-tree-conn-content .conn-label { + color: #8bc34a !important; + background: rgba(139, 195, 74, 0.15) !important; + padding: 2px 8px !important; + border-radius: 3px !important; + font-size: 11px !important; + margin-left: auto !important; +} + +.kundenkarte-tree-conn:hover .conn-label, +.kundenkarte-tree-conn-content:hover .conn-label { + background: rgba(139, 195, 74, 0.25) !important; +} + +/* Connection tooltip (shown on hover) */ +.kundenkarte-conn-tooltip { + position: absolute !important; + z-index: 10000 !important; + background: #1e1e1e !important; + border: 1px solid #27ae60 !important; + border-radius: 6px !important; + box-shadow: 0 4px 16px rgba(0,0,0,0.5) !important; + padding: 12px 15px !important; + min-width: 220px !important; + max-width: 320px !important; + display: none !important; + pointer-events: none !important; + font-family: inherit !important; + font-size: 13px !important; +} + +.kundenkarte-conn-tooltip.visible { + display: block !important; +} + +.kundenkarte-conn-tooltip-header { + display: flex !important; + align-items: center !important; + gap: 8px !important; + margin-bottom: 10px !important; + padding-bottom: 8px !important; + border-bottom: 1px solid #333 !important; +} + +.kundenkarte-conn-tooltip-header i { + color: #27ae60 !important; + font-size: 16px !important; +} + +.kundenkarte-conn-tooltip-header .conn-route { + color: #e0e0e0 !important; + font-weight: 500 !important; +} + +.kundenkarte-conn-tooltip-fields { + display: grid !important; + grid-template-columns: auto 1fr !important; + gap: 4px 10px !important; +} + +.kundenkarte-conn-tooltip-fields .field-label { + color: #888 !important; + font-size: 0.9em !important; +} + +.kundenkarte-conn-tooltip-fields .field-value { + color: #e0e0e0 !important; +} + +.kundenkarte-conn-tooltip-hint { + margin-top: 10px !important; + padding-top: 8px !important; + border-top: 1px solid #333 !important; + color: #666 !important; + font-size: 0.85em !important; + text-align: center !important; +} + +.kundenkarte-conn-tooltip-hint i { + margin-right: 5px !important; +} + +/* ======================================== + MOBILE / COMPACT VIEW + ======================================== */ + +/* Mobile view toggle button */ +.kundenkarte-view-toggle { + display: none !important; +} + +@media (max-width: 768px) { + .kundenkarte-view-toggle { + display: inline-flex !important; + align-items: center !important; + gap: 5px !important; + padding: 6px 12px !important; + background: #333 !important; + border: 1px solid #555 !important; + border-radius: 4px !important; + color: #ddd !important; + cursor: pointer !important; + font-size: 12px !important; + } + + .kundenkarte-view-toggle.active { + background: #2a4a5e !important; + border-color: #3a6a8e !important; + } +} + +/* Compact mode styles */ +.kundenkarte-tree.compact-mode .kundenkarte-tree-row { + min-height: 32px !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-item { + padding: 4px 8px !important; + font-size: 13px !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-badges, +.kundenkarte-tree.compact-mode .kundenkarte-tree-label-info, +.kundenkarte-tree.compact-mode .kundenkarte-tree-type { + display: none !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-actions { + opacity: 0 !important; + transition: opacity 0.2s !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-item:hover .kundenkarte-tree-actions, +.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-actions { + opacity: 1 !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-files { + margin-left: auto !important; +} + +/* Mobile-specific overrides */ +@media (max-width: 768px) { + /* Tree container */ + .kundenkarte-tree { + padding: 5px 0 !important; + } + + /* Tree row */ + .kundenkarte-tree-row { + min-height: 40px !important; + } + + /* Tree item - touch-friendly */ + .kundenkarte-tree-item { + padding: 8px 10px !important; + font-size: 14px !important; + min-height: 40px !important; + } + + /* Hide badges and extra info on mobile by default */ + .kundenkarte-tree-badges, + .kundenkarte-tree-label-info { + display: none !important; + } + + /* Smaller type badge */ + .kundenkarte-tree-type { + font-size: 10px !important; + padding: 2px 4px !important; + } + + /* Touch-friendly actions */ + .kundenkarte-tree-actions { + gap: 12px !important; + } + + .kundenkarte-tree-actions a { + padding: 8px !important; + } + + /* Cable lines - thinner on mobile */ + .cable-line { + width: 12px !important; + min-width: 12px !important; + } + + /* File badge */ + .kundenkarte-tree-file-badge { + padding: 4px 8px !important; + font-size: 12px !important; + } + + /* System tabs - scrollable */ + .kundenkarte-system-tabs-wrapper { + flex-direction: column !important; + align-items: stretch !important; + } + + .kundenkarte-system-tabs { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + padding-bottom: 5px !important; + } + + .kundenkarte-system-tabs::-webkit-scrollbar { + height: 4px !important; + } + + .kundenkarte-tree-controls { + justify-content: center !important; + margin-top: 8px !important; + flex-wrap: wrap !important; + gap: 8px !important; + } + + /* Uniform button sizes on mobile */ + .kundenkarte-tree-controls .button, + .kundenkarte-tree-controls .button.small, + .kundenkarte-tree-controls a.button, + .kundenkarte-tree-controls a.button.small, + .kundenkarte-tree-controls button.button, + .kundenkarte-view-toggle { + flex: 1 1 auto !important; + min-width: 80px !important; + max-width: 150px !important; + padding: 10px 8px !important; + font-size: 12px !important; + text-align: center !important; + justify-content: center !important; + white-space: nowrap !important; + } + + /* Hide button text on very small screens, show only icons */ + .kundenkarte-tree-controls .button span, + .kundenkarte-tree-controls a.button span { + display: inline !important; + } + + .kundenkarte-tree-controls .button i, + .kundenkarte-tree-controls a.button i { + margin-right: 4px !important; + } + + /* Compact toggle indicator */ + .kundenkarte-tree-toggle { + padding: 10px !important; + } + + /* Hide spacer on mobile */ + .kundenkarte-tree-spacer { + display: none !important; + } + + /* Tooltip adjustments for mobile */ + .kundenkarte-tooltip { + max-width: 90vw !important; + min-width: 200px !important; + } + + .kundenkarte-file-preview { + max-width: 85vw !important; + } + + .kundenkarte-file-preview-thumbs { + justify-content: flex-start !important; + } + + .kundenkarte-file-preview-thumb { + width: 50px !important; + height: 50px !important; + } +} + +/* Very small screens */ +@media (max-width: 480px) { + .kundenkarte-tree-item { + padding: 6px 8px !important; + } + + .kundenkarte-tree-type { + display: none !important; + } + + .kundenkarte-tree-actions a { + font-size: 14px !important; + } + + /* Only show most important actions */ + .kundenkarte-tree-actions a:not(:first-child):not(:nth-child(2)) { + display: none !important; + } + + .cable-line { + width: 10px !important; + min-width: 10px !important; + } + + /* Buttons on very small screens - 2x2 grid */ + .kundenkarte-tree-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 6px !important; + width: 100% !important; + } + + .kundenkarte-tree-controls .button, + .kundenkarte-tree-controls .button.small, + .kundenkarte-tree-controls a.button, + .kundenkarte-tree-controls a.button.small, + .kundenkarte-tree-controls button.button, + .kundenkarte-view-toggle { + min-width: unset !important; + max-width: unset !important; + width: 100% !important; + padding: 10px 6px !important; + font-size: 11px !important; + } +} + +/* Expanded item in compact mode - show details */ +.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded { + background: #2a2a2a !important; + flex-wrap: wrap !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-badges, +.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-label-info { + display: flex !important; + width: 100% !important; + margin-top: 8px !important; + padding-top: 8px !important; + border-top: 1px solid #444 !important; +} + +.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-badges { + flex-wrap: wrap !important; + gap: 6px !important; +} + + +/* ======================================== + AUTOCOMPLETE (jQuery UI) - Dark Mode + ======================================== */ + +/* Autocomplete dropdown container */ +.kundenkarte-autocomplete.ui-autocomplete, +.schematic-dialog .ui-autocomplete, +.schematic-edit-dialog .ui-autocomplete { + background: #2d2d44 !important; + border: 1px solid #555 !important; + border-radius: 4px !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important; + max-height: 250px !important; + overflow-y: auto !important; + overflow-x: hidden !important; + z-index: 100010 !important; +} + +/* Autocomplete list items */ +.kundenkarte-autocomplete.ui-autocomplete .ui-menu-item, +.schematic-dialog .ui-autocomplete .ui-menu-item, +.schematic-edit-dialog .ui-autocomplete .ui-menu-item { + padding: 0 !important; + margin: 0 !important; + border: none !important; +} + +.kundenkarte-autocomplete.ui-autocomplete .ui-menu-item-wrapper, +.schematic-dialog .ui-autocomplete .ui-menu-item-wrapper, +.schematic-edit-dialog .ui-autocomplete .ui-menu-item-wrapper { + padding: 10px 12px !important; + color: #fff !important; + font-size: 13px !important; + border-bottom: 1px solid #444 !important; + cursor: pointer !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +.kundenkarte-autocomplete.ui-autocomplete .ui-menu-item:last-child .ui-menu-item-wrapper, +.schematic-dialog .ui-autocomplete .ui-menu-item:last-child .ui-menu-item-wrapper, +.schematic-edit-dialog .ui-autocomplete .ui-menu-item:last-child .ui-menu-item-wrapper { + border-bottom: none !important; +} + +/* Hover and focus states */ +.kundenkarte-autocomplete.ui-autocomplete .ui-menu-item-wrapper.ui-state-active, +.kundenkarte-autocomplete.ui-autocomplete .ui-menu-item-wrapper:hover, +.schematic-dialog .ui-autocomplete .ui-menu-item-wrapper.ui-state-active, +.schematic-dialog .ui-autocomplete .ui-menu-item-wrapper:hover, +.schematic-edit-dialog .ui-autocomplete .ui-menu-item-wrapper.ui-state-active, +.schematic-edit-dialog .ui-autocomplete .ui-menu-item-wrapper:hover { + background: #3498db !important; + color: #fff !important; + border-color: #3498db !important; +} + +/* Clear button styling */ +.dialog-product-clear, +.edit-product-clear { + font-size: 18px !important; + font-weight: bold !important; + line-height: 1 !important; + transition: color 0.2s !important; +} + +.dialog-product-clear:hover, +.edit-product-clear:hover { + color: #e74c3c !important; +} diff --git a/css/kundenkarte_cytoscape.css b/css/kundenkarte_cytoscape.css new file mode 100755 index 0000000..cfe401f --- /dev/null +++ b/css/kundenkarte_cytoscape.css @@ -0,0 +1,402 @@ +/** + * KundenKarte Graph-Ansicht Styles + * Nutzt Dolibarr CSS-Variablen für Theme-Kompatibilität + */ + +/* Graph Container */ +.kundenkarte-graph-wrapper { + position: relative; + width: 100%; + max-width: 100%; + margin-top: 10px; + overflow: visible; + box-sizing: border-box; +} + +#kundenkarte-graph-container { + width: 100%; + max-width: 100%; + height: 400px; + min-height: 300px; + max-height: 80vh; + border: 1px solid var(--inputbordercolor, #3a3a3a); + border-radius: 4px; + background: var(--colorbackbody, #1d1e20); + overflow: hidden; + box-sizing: border-box; + resize: vertical; +} + +@media (max-width: 768px) { + #kundenkarte-graph-container { + height: 300px; + } +} + +/* Toolbar: Zweizeilig */ +.kundenkarte-graph-toolbar { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 0; +} + +.kundenkarte-graph-toolbar-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +/* Alle Buttons in der Graph-Toolbar einheitlich */ +.kundenkarte-graph-toolbar .button, +.kundenkarte-graph-toolbar button.button { + white-space: nowrap; + display: inline-flex; + align-items: center !important; + gap: 4px !important; + padding: 6px 12px !important; + font-size: 12px !important; + height: 30px !important; + box-sizing: border-box !important; +} + +/* Spacer: schiebt Anordnen/Speichern/Abbrechen nach rechts */ +.kundenkarte-graph-toolbar-spacer { + flex: 1; +} + +#btn-graph-wheel-zoom.active { + background: var(--butactionbg, #4390dc) !important; + color: #fff !important; + border-color: var(--butactionbg, #4390dc) !important; +} + +/* Bearbeitungsmodus: Container-Rahmen */ +#kundenkarte-graph-container.graph-edit-mode { + border-color: #5a9a6a; + box-shadow: 0 0 0 2px rgba(90, 154, 106, 0.3); +} + +/* Speichern-Button grün, Abbrechen-Button rot */ +.btn-graph-save { + background: #2e7d32 !important; + border-color: #2e7d32 !important; + color: #fff !important; +} + +.btn-graph-save:hover { + background: #388e3c !important; +} + +.btn-graph-cancel { + background: #c62828 !important; + border-color: #c62828 !important; + color: #fff !important; +} + +.btn-graph-cancel:hover { + background: #d32f2f !important; +} + +/* Legende */ +.kundenkarte-graph-legend { + display: flex; + gap: 15px; + padding: 8px 12px; + margin-top: 8px; + background: var(--colorbacktitle1, rgba(30, 30, 50, 0.8)); + border-radius: 4px; + font-size: 11px; + color: var(--colortext, #aaa); + flex-wrap: wrap; +} + +.kundenkarte-graph-legend-item { + display: flex; + align-items: center; + gap: 5px; +} + +.kundenkarte-graph-legend-line { + width: 20px; + height: 2px; + display: inline-block; +} + +.kundenkarte-graph-legend-line.cable { + background: #5a8a5a; +} + +.kundenkarte-graph-legend-line.passthrough { + background: none; + border-top: 2px dashed #505860; + height: 0; +} + +.kundenkarte-graph-legend-line.hierarchy { + background: none; + border-top: 2px dotted #6a7a8a; + height: 0; +} + +.kundenkarte-graph-legend-box { + width: 16px; + height: 12px; + border-radius: 2px; + display: inline-block; +} + +.kundenkarte-graph-legend-box.building { + background: #2a2b2d; + border: 1px dashed #4390dc; +} + +.kundenkarte-graph-legend-box.device { + background: #2d4a3a; + border: 2px solid #5a9a6a; +} + +/* Loading Overlay */ +.kundenkarte-graph-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + color: var(--colortext, #aaa); + font-size: 14px; + z-index: 10; + border-radius: 4px; +} + +.kundenkarte-graph-loading i { + margin-right: 8px; +} + +/* Graph Tooltip */ +.kundenkarte-graph-tooltip { + position: absolute; + z-index: 100; + background: var(--colorbacktabcard1, #1e2a3a); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0; + font-size: 12px; + color: var(--colortext, #ccc); + max-width: 320px; + min-width: 200px; + pointer-events: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +.kundenkarte-graph-tooltip .tooltip-header { + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + margin-bottom: 0; +} + +.kundenkarte-graph-tooltip .tooltip-title { + font-weight: bold; + color: var(--colortextlink, #7ab0d4); + font-size: 13px; + line-height: 1.3; +} + +.kundenkarte-graph-tooltip .tooltip-title i { + margin-right: 4px; + opacity: 0.8; +} + +.kundenkarte-graph-tooltip .tooltip-type { + color: var(--colortext, #999); + font-size: 11px; + margin-top: 3px; + opacity: 0.7; +} + +.kundenkarte-graph-tooltip .tooltip-system { + background: rgba(255, 255, 255, 0.08); + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + margin-left: 4px; +} + +.kundenkarte-graph-tooltip .tooltip-fields { + padding: 6px 14px; +} + +.kundenkarte-graph-tooltip .tooltip-field { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 3px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.kundenkarte-graph-tooltip .tooltip-field:last-child { + border-bottom: none; +} + +.kundenkarte-graph-tooltip .tooltip-field-label { + color: var(--colortext, #888); + opacity: 0.6; + font-size: 11px; + white-space: nowrap; +} + +.kundenkarte-graph-tooltip .tooltip-field-value { + color: var(--colortext, #eee); + text-align: right; + font-weight: 500; +} + +.kundenkarte-graph-tooltip .tooltip-field-badge { + color: #fff; + text-align: right; + font-weight: 600; + font-size: 11px; + padding: 1px 8px; + border-radius: 4px; + white-space: nowrap; +} + +.kundenkarte-graph-tooltip .tooltip-footer { + padding: 6px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + gap: 10px; + font-size: 11px; + color: var(--colortext, #888); + opacity: 0.7; +} + +.kundenkarte-graph-tooltip .tooltip-file-badge { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.kundenkarte-graph-tooltip .tooltip-file-badge i { + font-size: 10px; +} + +/* Leer-Zustand */ +.kundenkarte-graph-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + color: var(--colortext, #666); + font-size: 14px; + opacity: 0.6; +} + +.kundenkarte-graph-empty i { + font-size: 48px; + margin-bottom: 16px; +} + +/* Kontextmenü (Rechtsklick auf Node) */ +.kundenkarte-graph-contextmenu { + position: absolute; + z-index: 200; + background: var(--colorbacktabcard1, #1e2a3a); + border: 1px solid var(--inputbordercolor, #3a6a8e); + border-radius: 6px; + padding: 4px 0; + min-width: 160px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5); +} + +.kundenkarte-graph-contextmenu .ctx-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + color: var(--colortext, #ddd); + text-decoration: none; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} + +.kundenkarte-graph-contextmenu .ctx-item:hover { + background: var(--butactionbg, #4390dc); + color: #fff; +} + +.kundenkarte-graph-contextmenu .ctx-item i { + width: 16px; + text-align: center; + font-size: 12px; +} + +.kundenkarte-graph-contextmenu .ctx-delete { + color: #e57373; +} + +.kundenkarte-graph-contextmenu .ctx-delete:hover { + background: #c62828; + color: #fff; +} + +/* Suchfeld als Overlay im Graph-Container */ +.kundenkarte-graph-search-floating { + position: absolute; + top: 8px; + right: 8px; + z-index: 20; +} + +.kundenkarte-graph-search-floating input { + padding: 6px 10px 6px 28px; + border: 1px solid var(--inputbordercolor, #3a3a3a); + border-radius: 4px; + background: var(--colorbackbody, #1d1e20); + color: var(--colortext, #ddd); + font-size: 12px; + width: 180px; + opacity: 0.7; + transition: opacity 0.2s, width 0.2s; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 8px center; +} + +.kundenkarte-graph-search-floating input:focus { + outline: none; + border-color: var(--butactionbg, #4390dc); + opacity: 1; + width: 220px; +} + +/* Mobile Anpassungen */ +@media (max-width: 768px) { + .kundenkarte-graph-toolbar-row { + gap: 4px; + } + + .kundenkarte-graph-toolbar .button, + .kundenkarte-graph-toolbar button.button { + padding: 8px 8px !important; + font-size: 11px !important; + height: 28px !important; + } + + .kundenkarte-graph-search-floating input { + width: 140px; + } + + .kundenkarte-graph-search-floating input:focus { + width: 160px; + } +} diff --git a/css/pwa.css b/css/pwa.css new file mode 100644 index 0000000..0b917e4 --- /dev/null +++ b/css/pwa.css @@ -0,0 +1,982 @@ +/** + * KundenKarte PWA Styles + * Mobile-First, Touch-optimiert, Dark Mode + */ + +:root { + --primary: #3498db; + --primary-dark: #2980b9; + --success: #27ae60; + --warning: #f39c12; + --danger: #e74c3c; + + --bg-body: #1a1a2e; + --bg-card: #16213e; + --bg-input: #0f3460; + --bg-header: #0f3460; + + --text: #eee; + --text-muted: #888; + --text-dim: #666; + + --border: #2a2a4a; + --border-light: #3a3a5a; + + --shadow: 0 4px 20px rgba(0,0,0,0.4); + --radius: 12px; + --radius-sm: 8px; + + /* Safe areas für notches */ + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--bg-body); + color: var(--text); + font-size: 16px; + line-height: 1.4; +} + +/* ============================================ + APP CONTAINER + ============================================ */ + +.app { + height: 100%; + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ============================================ + SCREENS + ============================================ */ + +.screen { + display: none; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.screen.active { + display: flex; +} + +/* ============================================ + HEADER + ============================================ */ + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + padding-top: calc(12px + var(--safe-top)); + background: var(--bg-header); + border-bottom: 1px solid var(--border); + min-height: 60px; + flex-shrink: 0; +} + +.header h1 { + flex: 1; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-spacer { + width: 44px; +} + +.btn-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--text); + cursor: pointer; + transition: background 0.2s; + position: relative; +} + +.btn-icon:active { + background: rgba(255,255,255,0.1); +} + +.btn-icon svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +.sync-btn { + position: relative; +} + +.sync-badge { + position: absolute; + top: 4px; + right: 4px; + background: var(--danger); + color: #fff; + font-size: 11px; + font-weight: bold; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +.sync-badge.hidden { + display: none; +} + +/* ============================================ + LOGIN SCREEN + ============================================ */ + +.login-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; +} + +.login-logo { + width: 80px; + height: 80px; + background: var(--primary); + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; +} + +.login-logo svg { + width: 48px; + height: 48px; + fill: #fff; +} + +.login-title { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; +} + +.login-subtitle { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 32px; +} + +.login-form { + width: 100%; + max-width: 320px; +} + +/* ============================================ + FORMS + ============================================ */ + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 14px 16px; + font-size: 16px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + border-color: var(--primary); +} + +.form-group input::placeholder { + color: var(--text-dim); +} + +.error-text { + color: var(--danger); + font-size: 13px; + margin-top: 12px; + text-align: center; + min-height: 20px; +} + +/* ============================================ + BUTTONS + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 24px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:active { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--border); + color: var(--text); +} + +.btn-success { + background: var(--success); + color: #fff; +} + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.btn-large { + width: 100%; + padding: 16px; + font-size: 17px; +} + +/* ============================================ + SEARCH + ============================================ */ + +.search-container { + padding: 16px; + flex-shrink: 0; +} + +.search-box { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.search-box svg { + width: 22px; + height: 22px; + fill: var(--text-muted); + flex-shrink: 0; +} + +.search-box input { + flex: 1; + background: transparent; + border: none; + color: var(--text); + font-size: 16px; + outline: none; +} + +.search-box input::placeholder { + color: var(--text-dim); +} + +/* ============================================ + LISTS + ============================================ */ + +.list { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0 16px 16px; +} + +.list-item { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s; +} + +.list-item:active { + background: var(--bg-input); + transform: scale(0.99); +} + +.list-item-icon { + width: 44px; + height: 44px; + background: var(--primary); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.list-item-icon svg { + width: 24px; + height: 24px; + fill: #fff; +} + +.list-item-content { + flex: 1; + min-width: 0; +} + +.list-item-title { + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-item-subtitle { + font-size: 13px; + color: var(--text-muted); + margin-top: 2px; +} + +.list-item-arrow { + width: 20px; + height: 20px; + fill: var(--text-dim); +} + +.list-empty { + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} + +/* ============================================ + ANLAGEN GRID + ============================================ */ + +.anlagen-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px; +} + +.anlage-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.anlage-card:active { + background: var(--bg-input); + transform: scale(0.98); +} + +.anlage-card-icon { + width: 56px; + height: 56px; + background: var(--success); + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; +} + +.anlage-card-icon svg { + width: 32px; + height: 32px; + fill: #fff; +} + +.anlage-card-title { + font-size: 14px; + font-weight: 600; + text-align: center; + word-break: break-word; +} + +/* ============================================ + EDITOR + ============================================ */ + +.editor-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 16px; + padding-bottom: 100px; +} + +.panel-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); +} + +.panel-title { + font-size: 16px; + font-weight: 600; +} + +.panel-actions { + display: flex; + gap: 8px; +} + +.panel-body { + padding: 12px; +} + +/* Hutschiene */ +.carrier-item { + background: var(--bg-input); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + margin-bottom: 10px; + overflow: hidden; +} + +.carrier-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(255,255,255,0.03); +} + +.carrier-label { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); +} + +.carrier-te { + font-size: 12px; + color: var(--text-dim); +} + +.carrier-body { + padding: 10px; + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 50px; +} + +/* Equipment Block */ +.equipment-block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 44px; + height: 60px; + padding: 4px 8px; + background: var(--primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.equipment-block:active { + transform: scale(0.95); +} + +.equipment-block-type { + font-size: 11px; + font-weight: bold; + color: #fff; +} + +.equipment-block-value { + font-size: 13px; + font-weight: bold; + color: #fff; +} + +.equipment-block-label { + font-size: 9px; + color: rgba(255,255,255,0.7); + max-width: 50px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Add Button in Carrier */ +.btn-add-equipment { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + height: 60px; + padding: 8px; + background: transparent; + border: 2px dashed var(--border-light); + border-radius: 6px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; +} + +.btn-add-equipment:active { + background: rgba(255,255,255,0.05); + border-color: var(--primary); + color: var(--primary); +} + +.btn-add-equipment svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +/* Add Carrier Button */ +.btn-add-carrier { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px; + background: transparent; + border: 2px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-add-carrier:active { + border-color: var(--primary); + color: var(--primary); +} + +.btn-add-carrier svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +/* ============================================ + FAB (Floating Action Button) + ============================================ */ + +.fab-container { + position: fixed; + bottom: 24px; + right: 24px; + padding-bottom: var(--safe-bottom); + z-index: 100; +} + +.fab { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px; + background: var(--primary); + color: #fff; + border: none; + border-radius: 30px; + font-size: 15px; + font-weight: 600; + box-shadow: var(--shadow); + cursor: pointer; + transition: all 0.2s; +} + +.fab:active { + transform: scale(0.95); + background: var(--primary-dark); +} + +.fab svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +/* ============================================ + MODALS + ============================================ */ + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.7); + display: none; + align-items: flex-end; + justify-content: center; + z-index: 1000; + padding: var(--safe-bottom); +} + +.modal.active { + display: flex; +} + +.modal-content { + width: 100%; + max-width: 500px; + max-height: 85vh; + background: var(--bg-card); + border-radius: var(--radius) var(--radius) 0 0; + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 0.3s ease; +} + +.modal-small { + max-height: auto; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; +} + +.modal-close { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 28px; + cursor: pointer; + border-radius: 50%; +} + +.modal-close:active { + background: rgba(255,255,255,0.1); +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.modal-footer { + display: flex; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +.modal-footer .btn { + flex: 1; +} + +/* ============================================ + TYPE GRID + ============================================ */ + +.step { + display: none; +} + +.step.active { + display: block; +} + +.step-label { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.type-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.type-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px 8px; + background: var(--bg-input); + border: 2px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + transition: all 0.2s; +} + +.type-btn:active, +.type-btn.selected { + border-color: var(--primary); + background: rgba(52, 152, 219, 0.2); +} + +.type-btn-icon { + font-size: 24px; + margin-bottom: 6px; +} + +.type-btn-label { + font-size: 12px; + font-weight: 600; + text-align: center; +} + +.te-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.te-btn { + padding: 20px; + background: var(--bg-input); + border: 2px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.te-btn:active, +.te-btn.selected { + border-color: var(--primary); + background: rgba(52, 152, 219, 0.2); +} + +/* Value Quick Select */ +.value-quick { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.value-chip { + padding: 10px 16px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.value-chip:active, +.value-chip.selected { + background: var(--primary); + border-color: var(--primary); +} + +/* ============================================ + OFFLINE BAR + ============================================ */ + +.offline-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 10px; + padding-bottom: calc(10px + var(--safe-bottom)); + background: var(--warning); + color: #000; + text-align: center; + font-size: 13px; + font-weight: 600; + z-index: 500; +} + +.offline-bar.hidden { + display: none; +} + +/* ============================================ + TOAST + ============================================ */ + +.toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%) translateY(100px); + padding: 14px 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + box-shadow: var(--shadow); + opacity: 0; + transition: all 0.3s; + z-index: 2000; + max-width: 90%; + text-align: center; +} + +.toast.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.toast.success { + border-color: var(--success); +} + +.toast.error { + border-color: var(--danger); +} + +/* ============================================ + UTILITIES + ============================================ */ + +.hidden { + display: none !important; +} + +.text-center { + text-align: center; +} + +.text-muted { + color: var(--text-muted); +} + +/* Loading Spinner */ +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} diff --git a/img/README.md b/img/README.md new file mode 100755 index 0000000..eff8424 --- /dev/null +++ b/img/README.md @@ -0,0 +1,14 @@ + +Directory for module image files +-------------------------------- + +You can put here the .png files of your module: + + +If the picto of your module is an image (property $picto has been set to 'kundenkarte.png@kundenkarte', you can put into this +directory a .png file called *object_kundenkarte.png* (16x16 or 32x32 pixels) + + +If the picto of an object is an image (property $picto of the object.class.php has been set to 'myobject.png@kundenkarte', then you can put into this +directory a .png file called *object_myobject.png* (16x16 or 32x32 pixels) + diff --git a/img/pwa-icon-192.png b/img/pwa-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8028ab09e9c243f9e6dba5973bb76e49de3c181b GIT binary patch literal 1979 zcmZ{ke>BtkAICqN&6?HCuSkmJrkG#5qlp$HKW}DBB>6QWDQVa5Qq07a9}zWUli!rz zE^f&UyJeR`Tw!H?jZ&?y`EAU6cfaTR{hssvzwC#yDz%a4g;l)_6vj;LGyn=9Z-L&v}c2@x0gHL}JO*yvU=Pf4-VOE!=oM zzSJ?X+{9fl&F5kB#%hLzwZp=W$z{H9!}8(yFMRV?>A<{TWiyi_sCp~( zt(!T@82h#=e#To0ZTUc~kiKWarf9+}pJ&eG5y~f>$|hUhE!K@J)Q>C>%BM{7xJJbl zsK+;s)COYm`%zUXKP_$QK91F?2{K`DueN+ZJ-%tq82&jg=@s*Y zfVD3N73()r0gG@X*uuVmRlnQ2lYn)+^ZATj`*O>R;3W7a{@XB_uVwY4Z>GB@aM=lWC00j_K&Q?J=z-=H^zXX|*iug+s zc~a?p1Oa1gYU)0>#i%5PIU5r$R^+L3emN6X)7%^V8*jexzUJD42WRi2(;z4!IY))) z7Fo0-uCM2O2ww0ODnLyA?3~m%{k42`Iq#-;S5PDS>l{zyB|+G<$DNo?dne8yaGm-U zTkrS8tEqQiuQGm5gsA^?J3n*=d)Z1xIf{LUJ6{uc5std zn1bDV)Mt11R;zNGZh^SP2iGRP$pM)mqD(Ro&?wy0iGuLhbGYmlYV5j~eUnNzqheojmt`-7 z-dfQbl8=TUhpH1fWR5YQ;l|j?=<6S&pN}i!L!T( z@QUU_uge$AvyyAYSdDXDCAYp`+H|KEYAYJi-N_=iAZNG^2%Rw8vXYUpl3VQx#ri&X zzIY|4qO*!!ueNuN%8em^i$slWsQ7!|F%3>QumoGDAsd(~+;T%9{qI4Rvo|thBK0EG z#)qDe)OUx34z}MDZN-iqZ)=5hn$;w6sYzR zG=R8}Vii+j0=G;FN_`<@$5i1m!;x?HIE2Z5=uvcAW#c>#^=hyxUpITm+-N8H{N$im zE{90W)>X;Vq`S8EzmPX#Au|3>y_9L#rFMWA2Uh~xcWG_X<4PA zkzGSD>&W6TPGp{vF>|b75);&+&TP2D(hf6=m9(3s_st$M@bz#mxm{-q8@w40x@BoQ zd+6=M{!LXneN__@`YLkt)VXO$oMdr%y(qk6#dz7Dm|y Yr%-42rF&+VO21FQ(bn0f&YDd77s5T5MgRZ+ literal 0 HcmV?d00001 diff --git a/img/pwa-icon-512.png b/img/pwa-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..618e84ffcd519b5f6f51fc4fcfa62f0cdd492433 GIT binary patch literal 132489 zcma%ibyQSq7xx4+bVy4hsVLn!fC7Sqbax3zNY^j}NGOd8l7mRMh%^k+N=i2Z5(5k! zk{|cp*ZaQTAKzNvtTpSLbY@e#H&&3j^Op|xt*t%)pPg%_cIYzI1)GS33&WaUe|AWYtA=F*KQoWx8S*x`Tj|O ziUOfJ84$?x3erYE?ZRzM!xL%=q^d%^dey9>OSv@(8`YEF;^q#06?G@dOGV+V&(_}6 ze_rkd*PAgj9CPoZAh~Q`p>$#)PY2Fyu{G%e?rp`mj(b zHpktjON)o%1*xjD+p6MODJ(yLtZ=12^Tj2TcU9Tu0)Zv=V-(h{?W{T*Z80#;oIs0j zYDq_U7k4ha(Fn)9Wx$reuV7>1h;fl=TV`;3F*wIym$r`hP(6TsQUq%hpsBL8VOsW` z<;(n4S0kjjTqWj!1o5fSxx&*m;?`aB5seiF-|sHk`x|@$G))}wzmN}+S7sP;_#|Z!Cad21 zRTE7~T?I5$f(TgdoLP{8!CL#ycm5V|$92Z&!d_A9rVi2_vjdkSQR##P#L|76K<(HI zFo#X(?R@{fI395F6yNnr@h|ntwMLrtO%{p1I^$OGUO2_bNSnCM_D@Cx3 z2NQT%U@UDO4XTk_MwT}XY%GfYn3^T3$)4o$_hh{sLYsGal1UCXm{P$|=pge4&}xNP z{gcqDY^P2#{sMXj4@TPLvBww!{_zsLU|88NV;|hJN?c-umx3XJ6?t9pG~}EBp$qNV zbA$M9g`q6+g$7~Uz+<&D!Km4!i{VQmCu2_Ly?JEXCOJiOJ=yJ)psyx4iHb?|GQ`6o zy{_iUQTiO3d&Y`~s^|P}A`b(&;*VEh!b1M9aXTs<{DO0Qip{o=aYjBWj#L~)+j)D=fq&R!T%$vdK^ zw6`H`z4n^Rcq*IrG9?<3pJny=yl;o|5xt>fV`)}IrR)*d-Y1R|MD#g_?a<*WbPb^+ zl{|aaDTDeA3Enq{RmU>39IRO)hj9ce@XTE@An(Oq@(P~Gk4Lt#uEG=itt*>vMeGtH z;ZC%LLcik4;Y&ngVi6_;1(%;8s;JOFJsR83~*MfR@9X4__-GC$r@q(oVhRL&h4 zc;UGSW%jN2SW3mcM~S2v_a`d@h5MfobL=R)x*tb=YMOkGb6u$ zp!YV)0Vy>pWf~6<`w+jT>OQ|qG7=e@et0%Q++|p&EwSRw z!*bM{a~ukJ#-jZ7c690o&LX5GyROI@V%RKi7!(7+5RZ}drDDEjS(<({E^@V`zRm1G zVaP=rU;Nwho;NOOsKgIPRKC2UX31+wa={P+ecHDPGn}7?f{6CF54-z;LzPcgvEx67 zdIq9!2asKw_5(E}p%IbxOisZ>O)3VtQsD!YaiVe11F%R07m{r<{lmgu!1GrW*SCfT z7b-w3-b<}zKb@IQ_8*8lH}lj(w>F!=-n{Ht9&w{WsACO>Pri^*SyzX^M#b3o;+>5& z->5svy|H1$19ctA!+6}-u}&~U`~3OSDPZwr$IbuvcX zbze*3qZMc>oF73H%8dy)qaxeaJbrLp^4oXN8~<;x-y>c9FpOBv_T##!5O5JDH)=>Z*)&|JnTgq-0LNeQF;Q;6^ zwR4*wk>BkfJYc=9nws;*Ka{X82nfoJ1iqDG}hih-{g7DUfyj! zV|rG(1bFQf@h2yR3c3Qt(@(Hi0o<<>CYP~|tfLEQc>(gfyvu5O#P*8IPq7eLOT5f4 zKaYOEO@E8MPP+2t0UYYrYP&rlr$O^sUhnvNg537m*qvro-Jowsi?&zZDLh_VCv?T- zZk9>gZh~-Bf3R|(dvg>M>luf9RpLzQ|#pPuyx`|pHW zfOX(xp1Ed}uZLuy1M+#j!!W2xzX1s+9QAi1+m^p8Sv-I?V^Cz%Qtv?l)(6cg5e*5! zj)06u4s?LPDye!@obGlnFz$nh5gJaJNODF+M-KJLMaf!OhB~QILx&Lj)CrHtDEKSC z&;}I#K4*XVH#~)(7Ci1$E&~71b%u4sr+zEy1UARfNn=0(G^f$A?8r#&9O~3=V{3Ha z{Q~Hr?j@TZI{tn>xgTMaJm1K# zVo1l?LDPwlUy0Y<9oUNqDU%S$@cUke=DD21F~_fZmHYGGc4CuR*EWU_ z?@;!sy4igR{fZ&73&tmQ7Bc^Q-|l%9d%H-)|2Kv-q!8_>2`#I1izN33gxaYJx{&UbJD=ySdXY8&R*$IWohwv#=OrPSC$L_`9pzoTE1eMw8%eJ60T0%mKyZ!te%nfc~cXk4=zNY*KoGvI_f)motc8dJ&d6TV= zg|zY>&Iy~8VYy7NxwNatwcDZ3ug{CZ!^}>Co8LLPV3veQNBK$EnRSJ{Tc^P>1)~#( zIPLnJ4oHutX#+D7dt8yYXv-{{*#h%OSlO_Ne}agBU&b&!oyfO~fqUVruV1tf*}`6T zNpRtkBJ@*Mj!30(`p@k6h?>9>*&lzUF2JUV^`jdYO52nyY8d*Mios!s5^)3+J+<|i zSL-45xc$VlD;b~M%OeValz7KrpeElEXVl3H>~48@+DS6A|7#TH>=bLR$vwm7lX4*X z=5w6nfNZ5}E8Rpr&A9$phV@Cr(QOef$=saNGnG*|!#DbI__i$JUR1Se{DF|6s5_zZ zC$2&%UCp#G!MfNul&sd;2tnOzb781AQ(z+D=UfC?I{c|Nhxk9@!`t(vD0Y)6m#Vx- zjAe&uF45mZmb{l2SZ4|#{|@V}Ppp8&4-4JJowug8(kdHc(RjIgOP_F=x2L+@tWRU; zG{3KMj5h85V8A>E@`%bv6Nd@O+5BnM@DjCHdoiuI9>pP8-Q5q)15Jow9IwSbI%D%8!H2x-cKveUFNJ}R$Asu$eLL;0&bH9cforbnKER}zrX>s zYMQHI#vhru@K&l5s8KGi`K*NwqtU_;R}`)U8cRw3p&U2lz?&f}{PI73u2sVAwQ-ob z$5UlF4awwz6$D+G#>d<4@8HC^jwoyagNg&Ms5?OBF63Nthb@z2Y>vF5VXo~qHnXht z<+sOsU$D!S4JKnVUqWYRKZE;OhwAKBUyrj6ZMAG|f@5>05+xRJloStSr}U8b`T2y6 zp-Ku+;ONd{z{fZbox~#x$%|_qeZa@8=rg|SbDJRfyX#6ZR;+$BeibhW#YYa`pWRn6 zUGf@TMqPZHPG}?>pVw`k9|)z=_;t`LyXzUYznrXB>7w{MrT1keHY+XeIGJo_Vc>)W z=teV#P?q1xq2DK3Cnz7bK*Dt=-eNVEaf^#gDt=bk;Xj!>NFfjWC!`2JmGQcq&?SY> z^#wL~I`3@{Atm9?BLw10~8tk$r*P%XzQIfmq2gucbf zDzr7sY@m;a#9kr27;5p7Bvjw7z8hj59{O%0(&$9-G#}lIQL5xDbQ{yL>=S}|fD7}8 ztPB>tB$JwnC-!Jw<6cWr0g7k}lZ;I<`Q!8#fxRJ^<2)}8(kA8_d!mlY6hDZTBqz0x z@z;hh=N4~4%eg9%+sEmZaMk`7Yea@(&Gr_J5~ZyLT^{ByiPhNhn?c3hocV#cp_fsC zxx6^@YYH#qiM4~*aqhi7@u$_zZ^*oa^{>GQ5D~VPl|=?}M`JU^1xo)3uR22PM?=a8 zPoGOmvxJ5b2Wdi{4si*-&08csvd7HGrwGJU$j<~wXNNI8&dUP!QkU!NAoMO4z}{5#AeDDfkoRcA&C#s7B{5jDAeevee3En@5f@$Mt?4(gV~2OFgWM1YO;F3 zXm?~WmXwn(bjRY}25mNg$9oL_n60u(()!%9xY&lQcNHbMPTz=PoBfP}jY0Ftcy1;CYAQr7`i~QLJ2mj00YIPCnvI(bae}XYna!t;38-xPa&kvJV!2NvmPUI1{6| z3V!d|l`qikDz}=3yr)L!=_cYBk7lBs>!RIQ0(3qhYk{hfkN7-D!bCLn;w#lGdx7+W z>R8+b@4L5YBj1?xEMQBG^OBw!`FLePg z;Y&F^d>dk$6Jr0dALlS-sG?RrtC1@Ef2F??`N?&nY?~{pP{c>zRL<%(h|t3$qhA;2~I=gL{;Ut z+x$oX@cdBrp$O^FkmJ{T%DRA$s+Vk6(bsNvlJNW(oJrzls}D>MKa^W-hA(%-ZRt0KOtIcn69%gJsla2&y9oU56dnB29s&-Q`Bj$L(WXy zm86*0lTkh&suOrvJu;F%Vu}$T2qCC!CXsx7 zm+VryzO@mks``N^L)GwBaZpy3|i_-MRd@ ze&FifS`eZfC90u$j~-)CU~=bfOz6xHPR8mB;r&NJ6)t4_P1NyKI$uWq_OK?RC&qyE z#Fi~K>7Nk|(Cvt8Aoc^D)+7hplVW01)@yUliaO@723jK${!wE1Nd=7)n?h~yQ7LwD z09}EdM)jG3YxU=G`}YfU?l*Q=;WxLOB)=H%ciRU}hz^M zeRF?*{A)y4;`?Ji!yKOM+7m=x_Pg!mI(%vl)um{GN|)#Ub}u+|`{S5bkD)SZJ9%6n zn++9EUF`c(=!;E@uMI@1#h%xiw`*Abm>VD0TiVH>lJpO}tSAJJID%oQ*+0S`C9RYM zk#P9P=u8qh{@y=0ly$P%|0inb!O-!_Me&fAW9GtQl%;kwGtbuaZdin6p4==;JD=5LKtsr8!+hk540e7R*^_(|Ms#U? z$UVq7-mT`{dB=dKHZY%>vQG3pr1(@?f^eK#pJuXAq2Df=V%CJkzp3%59FY>&5kW3` zy;OF|R!hK`0valw3gcvbVjGqHNBtf^F4(N&8&XAAYs-}@_h7#E4054jx*=WQ#j+8d z!#OQak0qTQT*E-y{=H*ye$L`!ZWURq&u9u_MseqB6{>ao5mBU)ob6!^r7hLVdHxS% zv;ERam&R(aVi6hrlhc*c2ZvI3Z&_}gnw@HEaxcrB@UCay?vsuZ`NDTZ=^`%msmq`0 z9pAlHzIp(41y3+9oF?c*aSy&Hx;wIG=de1dyq&69{0L(+ zr1>JooRrGO*`#r5_n>LNt55c12cF?<(_!-IOr?vhKYu;W_#?L9Oam6dJ5y6S zsDGclwLipKeOs`Cu=Kh(zsB?K z{mZJ0wvJzwpO3l*$>$a$?dTWOQg;tJe-S6O!lwFbbml7L5Ue&fXO-q;S+c zwvV?tA7Om?XK^y`E*g9}m#>GX=ZzxHKlw_hAS5-Ldvk^s>)pT35*qUjN{LT`rX?VR zigeR5Pj?O1r6$#Xoe(tx)Rpaszi=uBXrPdLw`grf&lw2r+p|1}|Jtj~GONuj+n%ui zTDr{~200}9a)MbKgzH-kzs&U(>=$Qf8bNBj?l_Ih5MspR2BM$VTYdk43BqPfiMbX*{QV zcs}J8c#cfZ*|9zh@sq`8kQRMDHKQ>j2e#atzgh^$`aV0-bX&`HJZtk9A+lr8YV8*O z%i+yJdIqjsX7yfbM^)uT{VQzmV2-`hlIIS=kgt24IdE02>y9z#;QeEUQ9T9?FiVWi zHA_IXmH58uZq!5U63t_X#cY$iWln;+1=liLBg0?AP~3E_N%6nkS!=fI&OF-P}6>Sqodph5Y@rO>xIbEwO{uV4j6)LdU^R}O)rPO_MU zqkP-ihR3FAC04khn$izZ{}6c-2V2n7ol4b0F0vTDh7i?zb1_-p_N(3Ol?Lu%HJ-E* z{UUZYXmqyveM#lUE)sO2u=n*ev2ttRl@0lK6sF$K!k2&jK9-$8shprB2ZhyF9!^pp zp=JE+^_%cd@bcY#4`ZWGjA8N?E<^Vi_+yNBmrON@b$wo^GXCM1tuJP_NA+vSkF3yW_Hq2Uv)d|HpX>@T$7=7@k6WZYMW;? z#1<)|)T?|Ccwg>&RN>D4t~@;WG!U)!^WeLFdKIZ9y!A+Id|ctN`Jr+F*(}yvge$B? zC4Q^~R=cDD$MKd7Ou`lBIx#r8^M_rNg>X0*apDKulhXoXq@cr0qb$ez&)XQB`c@ur zH*w_7oa)lc}Dp%2;OK#6R#vk%g;&5b*{=>{n3h!Y`0Bu5fS#*S2#=w zj}ZXBw+$Hv^2}ewRavCOM(6ABefppCvv`5LT&H?eOu*ZO1cL;2iZh+WoX~FAFl1^0 zM_-}v{cjZAE>FD01dBy&3)$7C58_%Dhu<5w2;KTdoJ31ydi7fJsyex1C)_)JwC|H< z_@hUi3!S84%-OSrNfMP;Q)%Bbn^~sj)F%-4ECG#S#^q_rknnm4@k&zM2iNtsRZX-1 z;&=#$Nq5mZ=5BlP4#1D9A(ZNv<21lw!(SNZ+(t}^*QHKUds!Ih`F>C10=%oV?(9uv z%S#|E8lliuyU}QJ3$NM!(<2ZnNnA8RjSl%dq~Tf?%p&sWx)y{r_?>vqJ@?E%0=8W~ zU^$2X&B;kwJlS%)S7dps-Re=R>UUZC@%u$rEYB3}W=@rA*~A~ay~W~VbjQM)A8trE|G(+TUxT!$9BG~9i-k}>&b16rkcMv7ro&u`#kX#o2?j&kN}hF zOV~y2_=Zept*-p`T_5Ki9Bx;p`uQp=J^x?U=iY|ren-4MY?Iof zY;XqLW1n}A9~wbnUUv}fIB_@M_bKyaD(x@DE@c2qDD)bqjkJ<(U3P3hYJ|3!=HH@i zS9K0};WR* zq1Rt*L&Z!gmOS`(L{>{2a;zd_WFFv3h7&FjWs+tR&@p9xq4Np6(`x4~xRsP$%X=yy z+$7LmH4nDQqZ@cWb0rM-ecyagc=BPMs`$r}*_@7LzwDU$7VUilD#c_-Ws6OJ*1&5r zHMOjtd0~x1QU(?{0lrLDth;vzivZKwFg(X6tD;Zt{KreSIjKA0$ub8ZrQpCd{ zv*K3JQS`SD#P0D|87`Kh*I#XQ23xt7FaOBvBKG-o`C5*=2s@Z?l(DJZ3gxO5IfO_4 z(iL~djN6smzsD7aC=YDL>M6e~X+tY4cfZzZ@wua*$ibaWRlHb{f)Xzj{t#2@*XXU^ zGn&h-oPb&4c`8%l==0e^G4MV5&b7J`)Mw7=842ofW|NUlS3LTFN7Ujxr~r>8aW?>e zl#U@G=DzH*GpagGg_7G^4CG=Q|A$K}Tx3d^Fb2~kssl#loyc(!pX%Q~sqC%;PvI7) z)yr2v)?Jt?v%@1eL<-V*-!L)tdml#>OMRNI4}_t{rDH}|CTCro4nY*&cZ5RDpV3ZU zOOR=rW4y=ewXU9S6Vy_UAC_QknMpU}3#Z2XY3~_#b~nPd0|GzFl5Ej8O*L?=Z?2aw zOmBUS%Y864s(z9J4EckTS49_fhM6jXFA%FMWH`apnMYogzI)h-mt>98^D~=JDoJ>j z3GrsZxUdH{yk-WP1-`PNa#~~XeAs5+@rmR@lQ!0ADCzYv?Uj{GR z?J~e`r~6VV_>`Fq**HZS^mU>%ayoIqc87qwnVar~@z>(pdLu~`mcftu>j%on7*nMu zO@L?ObD6!UdF>P(z?Lcnk!aZwp_K{<_9qhG%^TwaV3tj4E)9TlETd*zV%CAD!^pX;Yf;t8+L#a^zA7^?iv z&pwD7XQRcbiJvl>$7%_((fPO-&NVoNoC|Qb@M=5Dkqnz`uQlhqB2-^E*Hfv9wMvDdKHCk|Q2(;N+SBZX6GN6~z< zmXE2+OHU%o#>9bR9_mL>rJ)g{veYM7jV@_5-}?EHLj%K7y+#cj_)U32c9A!Nxcia5R9>Zr`WEqt zQehQ2mp`Avk|5UtJ>ePEu{HTR^=$i+I;lY`1pR&D{S#zI`GDFPq);WE83QM_h8FOhTJ{hxxv-_rJzfR0q8Usw?M8TGq#WaZzR2K+6Y7FcY+<(* z_%$~xo-SE8m@Wwk#KABr{?R6~8I~2_O)=Im7A>Wl>XoWM$xR8(#-D0Se_q*G{;1($ zjuc2WF6xOV9qci`lf)crQYMkVz;49G`?EH6UkeN1KRqZ~$=OZa&N!Vkk7V6HS+-Xt zx~vfv%JL2m2j7~_o0o4OpjBiu2kjfF5H3Qb0U)@xyyVp%svq?{B`q^$m2|jv*bipW zU=8d%WQSs<=J(Dh3tL$IL&eBMDIf7E^*A})cB#9ky`n3> zva}*Je`i;A^m}UE23AZDV7r-qh!+*3UcR4&NIJPACVmd<-BLj&6DH>OMm9kf;YDkk zdnngcJAGt;Ql^Ryije}*U=@NOx}f-DL-5G5$n|q7Am))q9>m?%>=iowL&aE%;!0VE z$-Fc-oNGUvBJ`sCXO6w(sA-m8p9#P<4_odALSyglXJiwf^{pklayyiB|0bf{z!g(Gy}=X_!S;34N^a}z+9!Ia)JMR8+& zy%`I!bcyL)ZHSleJe+Qxla3O7GXFseDP>|YsIn-cG{iKj?W)Hp|0_8bB3|c+F4M&M zV>4vd4r-lqq9k;>KqDMjnO1F)46^q2V+Z%vA7UQh4mvKky)@YG&(j00R4%1)!AnGt zlBVZdytQQalsSwW3t(G1fe|zyhgZI-Sm@`xyKm7CZn_K>gXW8~AO1__qvYPbj0Ry* zH0lT+VGbfJ)x6aid6cY;+2 z{J?vy47fER7(_;E&GUd^|4(P{(#OVl3Um*d^p>E5uY%p$8h+5A0=8~t+N zcL4aI$<$haKdOQwKO4wKUhO$c%5sco?of+d5x{QFpD*^0(H|P3MCx&n)l*^6beVsR zkM+vW-psUoV0ZaYf8`dAThTnKTXCP{#Cyjk>iHAiy?vGddBKTuqL?(jUe0+^vSxw$ zAI^|yEOzFMXV-qynhDo9MUAw$r&LV z6)eZ8%|B)!ckDk!pRmymTlJPmQ&`81Ol?A*ocDt8g! zk4Ras*wgRb5FRf%*OPafID@eLtA;w+ca>6s%`7kU_EiUQa(xtF#wr1TE~Wb-oxI!hG@;?Ml{ZhBdM z1^)I3V6u8LBsbc*hwb!5xST+{zjC^zgklr@(cU5)m9p`=jWM5jf(rqEC-DUL zZD;S+P44114lMNFtm%^G8_9QW%iSAB;ry`LHCH(B1jp?|7M?~uVJytTiJ@PRKZ9?s z=*-ex^Mv6n^duDiS{cS%$ZU1@rp*TI_y`>Oe=l*-Tu|BOlGj^3n%d1~%#z=Z>!oNs zVr1%Io$%Q2#54>N9gu0XgUy{LW2Z<`YaUn53?hx$o@ZI1pc3Jv{8T&1-^#KW&#Lq)V2~~?|of<1=l{|xe-;=wf}#{ex-Xk(O?p1@s+u= z3oJ_B4!M-!=s+sJSCimRfBO(AwysRU&!k1D=gWTwqey}y5=ZguNid><&z6oJmR-~+ zK;NWxh&@wWB+9R35)U=&!72Y~NdVhcr}zr9k+Vur$4hQd1&Yp9uoM3^Y%9}k&|j|c zf+`PmU8Z)Pa+Y_Qd>Wx@NyB7$*KeJj4>{9M|9u;Ej+uK;fMGZic+NUBB_3WyCf;dU zDvLzpuiiPrH&l-azG>VDG|$JA*1`2BpB0(^MFidfUs7lyG}bqNlN@FSeN`GFG}2_K zyc|ae%RyMG!GDi_{~tuYdZo>E<#DYK1rpt~og@-F5qJNfeN$wvgNqc_T{yRt*sf91tCLpScG1;XS z=vWwefuFB}y3Z26rhMr)^VMy_UN}~H3)j^lxS)l&>BQTj1*eAQ<;O6B!r?8TbQeA) z64P653wqwmg(J)ogAWhLb(KPXCj482@&2*3kdL~H*^;c9;}G=p&V~cQW)UPf7oJT% z#3m61fqidy1n=6!n2`WHqXmrA!(+f<2K4GV?k-BWYKp_pj2s}?(EM7vY-{z%53*Ct zIHC%QnOFHo;BZ`=q%K92>j(PBQOt`$zhaU5?%Q^*ij(f}6~Q%P!--D;H3!0_OjOYGszyPU550|9&2tRh5%2X0evw22ZCec`U=6)EcW21<6tkuL0;LGCEl zvQH4|E=Vgh<~>V*q=~)4IO82va+Wl+E@RNp_J}6P&uDvUVJU$|?gY{YDZdz*g>&r8 zHFnT5LrtD>x9V`{wiBzQ-S2%gz|}C2LwwUxPDV_xZFL|1(ty2ij{2mD7E?V(ncM)@yaIS*E3oHey4KC;ZWYkgWtMgwmqS2ZwbV^lOYx zJwemK(W3ySp}1$IVEf7#Jgr3o(CG{T#sv3?ocx{Yzqou_UZLE;Uve0<^)*5TDMKaoiR}$6E3+Qa#?a}hF;}kM?!?&PWizhZ1Q8I=lkV6s0Ukx#ihYrdqz-$b zL3|v(>b62e*cbXi^HT_ktz!F^4T{n)2~X+z1R~@eqmDjLGb?QNqv!xR-DRF*;#xuy z$-mGKOk{RwPT%2#=Nr+cD73ZxB!3GvY7ccwJwR;~Ol2EUun6*QKo-LHd2CMVrYcz( z{3oB?^z;^!R_#Pm5%b7BK7OeuQ}S*yH|Mb5P_00&!(5eQZ(8xi4J5$nnj-z;t5C)D zS^DT)7ZiSBv*Qz5$0Y+CzoUv09%Snc232_$@|tlzF>KKNe(g+k<4HsdFs4xKM4xR-KjDiCh<*Nn;5z&unejJC@l9Qi^1W9AkCt^ zgr~Gk?c8A*Q#opVkvY44G1|`5!ZSkWIWWlQI`&#UOAE&@!i_8mQ1xV^UUWf%*u8DD zYC^-5+vmG&*9+f&9*qyaAlH?7OS?Hw!m*JkHp^70F%jfyLCj;IPpm?McuRN>cgjJ2 z&o^;7(*m`Rsawk;8U{WdtY#T+AJ~tJ=(u0onniM~;ENgni=o^W19r2P-)BiD<2*7U#!&F@r^Ma}W6$m5-wg3oqzEmiH=S+VUHe8b z31#7cF9LHmw-@ni_cpLc%07($WunD_F_#RjHx2IA!n-fXtDOxSi27_1lY-K6$Z%4R zjOZ5{s8N#7%@!OfTOD$ARr)baLvUw9ri>5Oma#i%kJ}@2-_({jY*veIRl7%)>vQei zXXexF^Xuyuv~ES7evF?8!Vdafv=aMdbrq+L#xF5~JtYLb!h;d3Ia_QX8%vuB!zw9q zRcGtqjNF{hENNbWihEp%p4*!!KdY6e-H3hPrOVkGt;V(%6)`}Cc7-kZy2YX8JVdXT zaf=v3R}+zplp2G4hVoi6zmWINlh}Z9%J=opb3M5G7w55d{)ZO8)iE7%R9g0AXn;lm z#T07X{eGX%H2j5+|Xv$etQa!+)UW znThru+JB!R+qRk0X;trw!;ARf_wJSm$bP`7d#m{D^!ha5$uh2J=zE7JEBHM@vZq!4+~IO~8CIsCmve$l)tvH4v(qpV zUAdIhr94HC&7Smu`-UYYVaJV?OZ(q~s)cY2(uX_9`6KBJif(C9#bp1S$K9*Q@I9hP zD1D(hG6y5g7ZO4o6k2kwZywz@BO?0A<%GDessf-IUHbGa6~(HEb3;6%${rKm5W}bt z>Jd^Roz2tYi)R-lmpQ@N)oI3-I6GB9uf&#g4s`cTi~ngB@H42o{#!mXPC%;8m?Rzz z^vW(KvTxr~D)+8!2=yy^MHUBzX@&!vo>sou2}uIFPQf)}R>!n-k=X#+G_M|GDq5gA z_Pb;|9SP+12;XOF`MPHB#sGWVa-E5gvc{tY(9-Cj+ojID=+$`{yFtC&x@L~G#&b_G z8rA&|2eZ7DExzh4!2+c%MQ0S}QidUZE5LI&w;a0<@?2I|BPw#v6BXdIr06C*>KC2f zl)`%avU%Q_w3iASUeaICwSTWh!+=8-;r9%snCUND~Zk55|_{Jaf_SM!PtAJyqh{e9)s7F_Fe;YsSJu50*T$hF)-x%VYC}s#=ZGyLM@LEp zWnRObyRmD&_)kP|3$=Udg`SHYK=8JnH(>O7o+tVg-9p;udYTjTy?pg;+IO~1OH#(? zlMwAI&9dJE@1dIwqZymh;F5C#O?BRv#2ks813Frann63UI2H9mPj`TlioR=iheiw) z$zx8|6=VyioY?(Q6C+W=T4uc*c(hlg2wej7T-$`vU&Mj=&fnoeh-Oi;+hD8RIdDPA zdJTs?!qoJ*jTOVMupH0&yq#P9p+14)5-9t;EH`_(mV7VuPvcwE{w}%(2mB?c}oPcX# z_w2==!x(eoR#>!jxEHN0)y*D(`g?A6P$WAY_-1vwZ4MSMTwH~;M2>DcpUN4@KM*Y> zJNd!*<6CL1t`uI2J&D+wBKFxcoqovT(MWA2z)8-1+P^?yYNU!9%VOxOD0%#_r&(&Y zw}nVSy=+G}1W!NRA45HPYP@CYd+T8!7fdfwMHv_w(ckMHG<8nH#S)Kv^g5Q$_y7817&e;v8QaTKbfs6DE`Mn_i+FRE=6s-pU3~4OKor!zp=S z`v&S6V?{=1EmL+JITSFW4Q3H;lT?odizEvfk;F{5ZC+GI&Rps{BkMBV z>WBye!qxO7ZOtfxzGvX7%k^eGS4Z^JeWVYf_*M}$w`|`Y;l5&3AX3_2DBID(tWT*A zPue0Zi7Pa=j*s|JYtx@`zlj6JZb%J zI!ai!(HYhmCFMxik2=NY;aXWom6Er=MncSm%n>nD$hNN9gkZ12bOADg1EEzDK85P9 zTlAn`LrYDwP~>IK)Q7DM7@crUbrMpOb!YYSZF`y!#syp{_vTiSa5t0ZYn3f>7x3Bx z$>#YpBaP7F$I(^T?VAP$|MqR*Cb>xVj6wT1<`O6u-^;gd1|*e(p-bm?0wos|^C+%I zw@DKFRzMX{Ynd&SFE@MAfJlJkWPg)D1AbjW;NW^7EX9*U4Iy=!K(NSfoCqQM#XFJ;W4)aUgUS<$>dAg2$K6kP`&Ssv@^>J+H}QtxLMQLC$U8`FQic3bx1|8;rFbdL zBVS?Tdj)Agw!HO6wdnKxVCn>dc$< z9$&g$&%UeuqLF|j;EOr0Ji5e`DV$zY_qYX{%>-nc2+V7*?Mm8;kPL`b%XbQ%YY{(F zHX$wi&f@miPp(={IQ{(`k2{x320i`eab!|wVzdU7k~GeI=`(WaThH)7x7G{I!&?p_ zx=&t(Y+Zq{wdUH_IP*%)$jFPq;ds<|(M>s8R}s4+Zuvp-^*hwrP~v?{QaTFIkITED zB7NXLlqN*xZ%Xr*tfY02A()Rx_mA(;k1A<=5TNakiNIJcvb?(4+d@;|&W@w>p0CK4 z#Wvkag!z*>0Tq34&c<7#9cWg#S=X8jMIZ8=jVC=Wr{V!bf)64r+#+z$29xTMdGNe^ zPa21me^5ug?LxsU4+}kjEQ4!+eRddvVnkxlLwt9h$RF;w<-R7GleN5Un#4cVI8^UN z*?>s}A_a%e`Qx(XnM<ahqYNS9S7t2B3V)tqe8JyQW-vghL>5afx1Cg=)+B@7AsA7)``$6PU z*d4R&s!GX<;^fTmw+;p^*36)`CkMKEL|un8;1Oj5$cJbVFUX{CeZjg7Y@tiB z2_l@!IvB=OCijB_ZB)tvyJQu*g{A-v(JjdwXohhuu+z(iEPG3akCMYQAkuWXyYxTSOvbuS5{`n_ z?;HsCTycz!(&9)+JUfV?N6EMD!b&)VzX;A4t|?XKL^9y{Ux%-<&Y%O5hs z{=xhzZDhzjl-f817QiN8YWd%M%0v}2A;L`M|FHJfVNt#9`uEH*z|h@|AV?!6T?z;Y zqjYy8Al(dIq9~w<#2_i%9YcypcZYN%Dg7?L&))A7``y3av)^O?F|e3}gEfnL)_q@} z^E|J6I?ztJLZ@+xW?y^Q(^}%=^zpxmP>8f?q1Yto7qc=Un_@`u?AtfP3%R|RT3Ae3p5nK!kiwq zE@rZ|ct9-uYRMco5?~UhQiX!615as8195=;CLHVudi;1KhWyd>!(7=g=n=hVzxbVk z*r$b#a%RC3Ua_?1LEs(tQ^f(DGHat#)kbMj^(fNWQrWmX^6r7=d=+q=_!0A;IB4tJ=_UhG3I6$ZL8`Ad}1yKuv;Cs*dwIhR1KmDrc4M#zb4Lu~o z1d2A|BzpEK>@9cEd^FnK`t5D6pIVbRsM~tL7YN!8Olx(HDI$GQPCG}=SbJzM6<0;@ zs}U1<8+4S^;AeeBHNj@_pFatIlYoqfV_s7F$s=Pg@*5~$f1~PJK$VaZLm!s&fxQ}4 zmb`X1Uc6oZh}t2U4i{;Axp8XFH>K_yxy8s*Ip48`Kg_$wy6~y2{NabKbZ}vR!ObqV zu7}gOs~`ad)ZVU`>nu-QH!;kBI}7S4KEtzQG-KU~Q9QpK-MI zD}6LP#zyu%IsuG}oGm{C8@bSX9c;SCOb&~10}E)FNtN0|&rs9yiXAq|I7?HA#Amnsu4a3nU*vvqbH^kA1Nm&fQs zak*hK^F6ruVKNk1^(pwv;*gy&ocKzXmnot|)N^F`DRIcQms_Fz9Z1H+g%N7B@W|X& zn*g*pSAmRb{BQ;1bq#F-_9mel*BvceK8_m&H}#$t0bRQjjQhl2P7msM2GtEI#qD4V z_-f-`*v*{ZBa~P!k__;)Y`!rZ@`T0|{ifU3XOHbxcm&Y6nCF*S5@A~7o=0_(!<1vX4FMKmZ zs~^2)in0k4qgI#S3_PZ*7KmVU+SxfWkSDAS%%~ZE!lH{muESc{RExZRGqty03m1EE zmg@E}ahIWf+6OZx<8{mSHONY&yGnq*mmvRU22cJXuG(xevb3E5U~(pOUNEOBTV=9KntR% zkN!tCWB<=#f!t}LcFx5LZ)}w*Ps|%MVB5Mv$wt9#zT{?n_L|%Qs1zI0!jDY;p2)Yq z9te6%H@)=%k$U`avJJrJ=IP&}*v0D4=-Z-5#(J`7rGrs{T%O+IF2acSQQO43M=(+% z9Hg1XShdhFUIz^k==U3QTw-5;#SEGcob-6fFCHtippif^i@X~EtO0(f~dmy7|Yss zbLqnmwSuSZA*+^Q>B4L>%Xn01MKD2uiaivE?JK$c5!CIFs?7$}6D@tY5M|oqkWhn4 zZ6cXqyuDQpE{?>Zo;^9JCAXo!pM78x$KM@dWs{gsxw={9(S179#OU{G2`!2~gLX|Q zxZ>QN!R<=+7yY|~R9I0!-KEPFsqU-iYkjL*W!r}Z47&!I?!HAiZuI|mF;DJ3`DbhB zqg!q8AK8ZOFY!9cq}Bt=%bp`>jMwPQ@9gwEkr1={2z?FZs7;Kn@Ct+{^FU#Qz9=j1 z-neNQy#Nm!hf#3B@r#&D9pho57}i70L;MxK9zF7gP6;5h-N#;SEn6vB>5NkqPOjl< zfPNNk{cHDHhD`9~rzBVtx{pY9z{`D{iSOP15om0aCT*13ci%YYj#%z${q}0P`Q|5# z&x`LSpZ3`ZJt~%XM7PW2M3aUe?J!q+BB9jh%6oEOX7%f8u(1d`19jsx9TtuD<4bXF zv%SIbv^{0r;nJgs)xomu<$?=Okg`puu?KzVRQ~FZ1^Rf2k(YTmZIUlcP%hgfp!G(3 zkXPtw!@JV-h(nuTmHBVT>*1Mzd22#ZL7&)0T66AQ_2zWKoBN`@JFs{4-!qykftc?} zR5j(p0qj7BRP@ZU{}5=0w`E{)WetUrOnjq{G`KN)DHS4LdK=Z;kz8dgtOeZ-xVdMs z>Qc_@R@g-ai3K-qt6=S89=P8S=gEYwZ<*Bb%BcySWKPpPOj~jU-ob%bxbR}eIexw# zC7FH2cQ$=Q@C&;+H}!~3#47e+h%SL2!-1{R<2kew_p-)Dv{P)C<5N6Cf(4RGTS=Nn zZ*_EW-1AsJq2dA|FP&2eF{l&5(Je{Zv-hK&Z$%Tc0rxM;gy}Ekp_PZMCs+9jkf%Pp zP#`|p0XA;1?f+4P;as=h%QY0aS?-Th@OWNDk?&*S0VQ_ulkO+mB!E{0v?&skZok$j z-Wn?N>8_t(w>{CYZWyPf?(bL9zmk(>Y0f)E7fdF(o!$l2M~27tS;f<+9{1(M2UzDP z{KD1Mp}u-G;Aylxk-1zGH+xk~d@?3ld@3$KeLPLUXeMUjYmwQ+V!XND-fimMEKVAe zFzhrS1Kf+E~zvT|qC=L`8?iR}pg45mWX1DsX-Q`d4qmHcQUjOR0Bh z<~B}T51oc9De)I<1Na!=lYUMh-qFalC8J%Q2}#+EV(D`LbJQtzKBDxNn~T}xPP3w? z7s$#Mis}dV(a|ZgODp$-LvpA->Z@cfcfOwQdobkU+buOQ33oAQu#m(+zshTMd=`>= zW+y)$iToW0Mb#W_o1Ak?=ZRkhEX43XIb9LznUVZmtOqK1R9VUqS(Q-SIA-5dh}ygCQP&{?deFy{*0a$*LHVao{0dmn0jJ{U z;?qCkbZ4nb8X&dB;6f--TkZhCP`}CHXP0lz#fMmYn316z%c;u4gboO7e6?Y!>ztDh zz|fnFxKHyQsnQ4n7*Ylk=!!H;bSg%**l@SJ_uOnJ_uxm@Jf_8v`j_(*7euZ8=g(7Xq z!mdlLWydI0Ca{rmq1Mm?Eu=R@MP{C%ZNLsR*?+`VyV#%_%Ez(%n2TB(8@|qy2L^t< zZnPxTF8+GSt_P<1_$}QNhp|CSZ|gK{#++?4yv=x@oS(KST})#UVywD|T0yp~n6C$a z(j2wLQ*b-w*7{tcv)Aigff%nNMVxa`xQL7e?r3hli>D;n+cQKb8~oMGds}aIX8@Mpe%)RqwXR@p<8{pOA;K(SeL$%Dimn{X(r!p44^{- z+nvO1RR72$ehEwnpt4oBm>?)E#EC2TgJEC0F>XW)zoIJ~($mOLY&GgZ{QF$(5eHN< zoP3`P9WP~(k)m3~p)O`iCV-dBMNlJyu_xMOXsH6W;QVrmO> zYd}+JZfiGpF?4F7W`_=EIOn@cGi+|kaRxsrV#4b7vqxtOi79;N4#B99 z#uFBQ;PjLyxWB~v*}OK!O*4a%ni-K6a&{|H7u%Nw^OqFTro!^XJvnf5u_; z8TN6a&|Pf2zt}>ly{LH;eM46yEpdbhkWOSEZbStzaIkfD?2E!B#eZdrP}?_kK3n)f zc-3xvP#w97?bXZ!uB~j*Sj1SkYTz{qnTeGfkwqV~Jp<9NKx1T1fmBFv%__;4Kw-t3 z*WlV)Y{0O%M*Az(bv+8GhMF=7ML3|U#nD012@RY}g&Qf>Ku{gXb_)|xTIeMT>SOJ; z6e4(_Zn`g4HbBP|tdw7Po zPKgnQk>XbXJzT~&*=Svyp`X>_!}nx3=Qo0BJ^R@|FKYlrm;rys#s+oSaqpWW8AwW( z0R#vQZ%IUa(wNR-=X)Fo&M<15ozQ%RD9Q;nHBWO$@)JaGK(YBapix<&Uw&P)PSrU~ zth(n<;po{GA-7E>-wfjb8<%kEiFCWv7#rf`sV|5I^>W{82k^u1`IOe;H|d?$H=re-ef-@4Cy=y2uDFbw=%^l7+VJCjl z{W1+Svrz3jd%*(H=m!^&y6&sa_WOe&exFNx$y^PxjqD#Ir#DkSLfkUHRGiUy@g=G) za)AZ$COp21PWo6^LgCRFpqNQkMAQ1f<7UiF-1)RT8Fdv_#5M=pn4{2piJ=|4$JF^H ztHhSaX3-uGKwgT*mO$Zl=kS;Dcw6!!SRGiS&+e9Z??s?#{${(w9D40;`UvwKLGSZF znFIeUjz?6lX~tg}qW?$cfX?cfcI0o?B@?-Yd&|@UI4Nm~Mx?)K^;oG3YL;LfjIdc@biSQl&o+^ zY;O<``rJeQF6a?eGtd>U7PDo*;~XgIs~rqpE9CqV&04l!pTe7(bxnMD`#A}5x0;^& zIh4Myr4QI@OxCfI_^|ed_>kc`;fk8rd$)=BP&0)wiBG8-u`5RpM$&LAVR{sA=VAi^ z#PwQ4(@rg7_p7z6OSDg>J)T~8=640JR`l%hMTjeU)isAr%_3xkm5(^a_=gx%8N`)A zU3^-QQLuYEYX+g$qKDHHYkHd<;etu2GNy0|u65l!a0E|_zGyXx$S|K;Adk-p#OYZZ zZyl>e_^$yEv_b`)fW#(rVJTXN6)EWO!guAt(3zwG31{Z1AM;>ANa@GG=fIMq$>vSo z68MTRPV%UM9hG#A%F5d`xB%1Xr}T$kuFW6VSY^GHfiaZ%GE`&Yx1aMMzJgTWX9*U9)peAz^aXoD!mu zy}vnL(hDo2~V z@QXxk1~2k+%EckvtR{(PS!eL1h&eYDjuLKD|aaBro+nl z)_M7;19_qr1T)lIHS|Lx4I-mj<(e~x&58Pg9#tvvBBKNBc&~561H= z!9QiCzOC1a9W0K2QFCK|D9P!fer7`ZU2&vLuC=x9EWd@R(BuBqkH%DqA3F&a%wemE zw$dB&?CH;?p{}XoQw)#`vP9nEfNy#bt7eP892oS!92m%zHZRWZJREhwZ;PBCT#B; zW0PuzXIP=eyQM_p|C{*`r5ox;DtL$?=#HQe{+Q~3r6`|0X6f-c#=Zd?tmfEqbf-9D zjV1q|5I4_3siF}93-ClXo&10bvOq2%Cx~g;xq}--A zWMsE4G77DhabG}X(*}(Jl}$}!1Vvfc!pM+Z%#$Tj7Bo+-L_Pee+tll*WMn?mRrMNj zAeT?)ylCfz7@67B81U9DH?ZmhG9hSkvd3oZ5&{;vT4?><)(jNDG-dd{QMim(P{oo0 zIs;KZdSc4ADx_jys)n)i(1Jkqez2ncC@cHc<*RaJkwb{{ zCmrSU1;mjz{;T3faZmSR^r1eX%@OcTcxE{c-aEkaaF>v+|?4I#VXFkl6) zKC*EA3Or>9O1abi!o2)UO}VtiE zCz|KgWlpDfGo)$26<^M5uW7@1!Ur82n65q`gK^sqj zpZV}Fl7cg1j)Uvm!DKv_rWqQK{u+D_`aa*B&{|UW%Pj?N^S@V;r-qtC~ zRiOgE7eX2{uA**j>%(bx_}QnVZ>#{Dyh@#>KnmK0 ze;?#og>EHIqSbA%O=}!k)V|~|K%>c0NiZJo`N5;L0r#k&ovyz!(}(>k3Pm|JC8WLk zCTt`{mtn(}o%_%LE)|{XV1n6&bMLKKH#s*u#TLer)^?IA{L!Rictq-p| z7SV@4#Bv%Asi@?5{zq@r{A1rmwHAy;2W=Bja!{nIdLpxS$*eSuc@zu^9@<<>*?j=83Q$qZF^85bGk>f@moOOgY6bNJPZkH~yUS)8KjH~xWg z^%Iho2{I*ck5VESgwzq%43-q==^-DSCkZl(&IT~OQUeuvF)F^&$oW{i)JnU<)+T~) z&+u(dBH<}M-H+1|eH)<+pCjddhoY#uS>aRMEBp?`EZ>XqR%K4zq8}KGk=DLx+Ug6b zZVB?Gq7~cd4d&#szlEf0aZHoV&@nk||MZ->!jXv(4Qa!-bx#Z_W|6Mi>OM z`XC)v@r7r(ax?-Cgob_xF>(K?m=sk`vGXQ=XIZe0(;mvXY52{=08l2)Mgig-$CF#?_J9EBxvo-iS8h2+mdt&_K1S4r*^Og8gM!Ab20wa;Ko|UULEnQcAa`6U!^f2@fp#D~6g#$)xVQ+(=KmrV@!^%<#qn#N zq<_yjciI4*8@N;ppz%J~Y4OLhFYTXz@5|0p_cFsJr;}8-trf%aGoHPWwm{uP1^}(E zKh^J({cS*j-UI2)1dkw)Lu+2fgQ7h0leo(OZD>Jq{-5|m;q!((Q_$iL@i?*u!-+h=aIt(%^&kW3Q@|E%Ui6X-qM1?O z@*Zo>Yin49YRQP*-yGcW?Hh-AK=Fc&PVVi>2S8LavN6lRbL+-6EwC?=@6UaAP<~JD zetbe3zL&u3-C@>Y^Nw@3V`;-}WSs>Mi26x%s3VGN1?C6vv|g1(*jYMB)Ne!yT(tvGN#U>TBUMMu~jN@FhQEM;}@npnwmhs z2tQ(E3rHO1{&{Bt8=g6S0CD~zG5ys5O`e?llz$FN%WOO*-L(4()iqiR``5&~^!S&3 z5!8Hi2KOKKOwt4zWjgOZjxpSRCH9!T3$1-ki4R}SxMo=TC_}KpcqA)opEyJJ+F28w zE%a5E*qcJe!!N@o0eG2NMc=mxTcMXKJ$@{P7Jy5TP!S8skpJ;qPRKulIF-$6@63%2`ApcQ@H zs1@B>T?i*cr4L8Tp@#$b#EXV2Zoar1@?=irnvQ5CaKID%4t}CPffY;^f8{;%Jr|tG zugx%y(Rb9ZZ)$vEV7d>oh@MfB_H078Ry1aNHox&wQQ!2#B{$zkox2x{kmDrafmcLr zbo%@Vh|IYZ#a$8piOfk7*z1TLH$|KBdi{ya8DBnWMZe!xxfD>yJ93OrN$wUi#rRQU z8*KLnIByn`Z#^8H+kqB^&LEaQa{rXxi;l||;2+8bWwJrv_3eW(Z>dKf~^RJ&z% zSZ#mX1MB`ZcLdVN42bLFjELwbFy@K9%>Tud|6Dci8@}Pj8+_T>f%ogAc|(ow9itw< zH>n?oduU$=9z4v?k1^ROK~%tZwKwqpEc+|KvTp&zlg;41dD75gF4vM~a7=3qt}D3{ zKXi{)5|pa%oFhebg4OWgAf)#=5*eT`0(};6LL)zPbd9(#R4I%7*-Lf%_}yhQpw)22 zZWHsI%JZo&+QV`SOIHlw#oLjl#=UT*#>vZtO)Sy*ok&xj%3S#lC#ns7tsz^Z^*C)e z+Y^OuJ8gOP&BTRqYD~QKu-VW3jOS&C_3ICNZWb6qeE!aw;uf&=np@-R3BtayKEnSJ z{4>C9OC{dh@P~B?0Ox(_?g>01!hmg!;X4frs zvBkKlCpyNCUYLkMsPG{BpOSl5hG9j+_AtW69{2e_7qe=`ZyF6*MM zI!LH(>qSpGa4yH-=DhuS#q(I?zT|;&5Z;VPywat^(3@9^+Y*c@yu{F7RbnZ}!A4_Q zC(wrD`=4Zbg6>MP#Lbr4E`@9job2MaHpCUTJyh{1RQvI5RitF$8-Lo?KaM5}@^u&R z_F!5U{@BFZHL1l&P;k%XpGs>gboa`qC22)9654;8^_rnWRtaAw3eaosYEudHGHxK? zLnfi3qXog%7L{*t`~l1OyAjP4MG9*L4&-Gp+XXWp2Ofm<_~mJeN6S5slmXh_F4gfNHlsUM#rm104gsid_bl3d2N zNfhpZaFJblAVmKVJJgHPDqcX`J(>ZLs9%YkPM;<~=RkI`))zyr|s)CRg{;+F&0(y_VJIaT=mC$OBZ>`ZZ=QtI8{kt{K>h@sET=^;J+Yv3N ztVzC!&7L(@ol#twFI9U6ItKNmY$)DxK;M^sVwd9`5|?{ubRsG!4Nc>Po(cwkw1wx!M#o-&lG(#dTlHTe&S*MOm62@lSLunr@AtIMp zbSKWRdyzv{4ZUaL0GE5f#sm?z<-Zjd ze)QgZnVhxfyRC=~BPl@vuWUK!v*j-a~(B`)_YY)R$@lAYtKIeB>mmS5*@orCNzd$d7tmqb{wF=}IwW2kQYd@$B zTF_Oa_5yL#Y;M?p$Vm6T^9MVL%qNO2r7aH(VTRD>Z}>za=(8qwC{TL)&0hg0wXW0g z09)62Y|iF9Bn0Q0JoO)KRD-IeM-vwCvY^W!fI4ZHA*>kyi=k5m?b_lDc;4TBT{|Y) zgs&Igc3~}?uVl?oz_iD;6X-#;sueDtVk3VHKO+j}|07LK0n(&UxzxZgTz{!{$)8ws zvIReeLV@1nHMhPC&j=o>aE$X4^pZj{*-KXIlKau*SoM<2hI7@DCGp%BbVZI=z(lI^ zmq8k+0`#n$Vp`e5rvIxyxU-TP; zoTuUvggwTF3Rv>}p6Uy@LBe{A(hKmC7hej8IW|j&6=73;S4=Ehk?*Og{^NwMjuBR9 zf@+%OdycM|EG-I2PUU-MF6Dc^l-WNUSNx7WRVQ&)WdIr;|E^rhHbyQk%Zq#FS{t>z zbUO0smat-20)N|p$DBU-cuY_XtXS~C0GG+&J0I7Qob-_MN~FTqun6ew9nE`#aa+NA zv7r0}+eq}ODpnlLPx@*C$PkAu^wlf*M-=ZF8JfO{#?EG*(d5a!4e8__UP%Qu$M2fC z-JdKw({`wMc+Hd=DQi4>n6-{Iv>Uqn1*I``w0FsrI=*nR^k(#6vr9(UL;leq?*3UM z{_>pI(Bi^!;w_N#}Rx_2&bs_a#}FlEZkOpp(fG zJ<%lt|5(B1G({sLd@PI3RmX8niqpucUBLWA553VG43(AL;$pNSdJ;pZf7!>Pu`k&w zy{@Wy#5HIh663v4I++sI-!~`zbl@W0`NE(0{rn&`hBrxZdAETn7g4j%>Z7@X@|tUg zy(@e68LAlz=Y$_fmnV7w;?*&DHVqZa*AK7pR*io8)uiC%)wV~taV3hiDW-}rm2sqQ zRD})kh^x~>8mnp97%*+hcf0HfZFDM@B@*)SZ1AH>Qk9vX@4*PjX1K_x(Q0n5CT#_S zxZE)j3RxE`MB=}{L1y5BphWxJ4AIr+7)NIH$@1HvGJ4y5e9%GcMJmC%r$ufu1{U4i zxi&3`W%WeackEQe17wRnUHTObg$i?;P>d%(+SkBDSgyD$O)W1txrc?-?2~qY1__V3 zh@c$^a)*j@$f({GNWHMYJz=bt7&48EcW6)66>^IE6 zqeiJA(qWaU9|)yPYZITz?prZ_mihgJS3h|3`4%W%{scrwz$`qvsG1XKjv2JEG5>a* zMYbUEgZv3l6d7SX#`u-Y*!pH5ITo`kXAGVC2Zd;43>4Xe5ll}v+w-PJpM;4Y$UZ7=)1c_zHZl` zOM1P|U|V$#CsaQCG^zx2_Ud|m#8xn65!`?#lQo>$)CpPCjrG+<_rHKffx-)pf?Djs zfe0KWOd=wS&Mi|%!tfK&irFjHj<(Z?SFyh-ym>0EV`4RN4(*lvh7IiR%kB$(7cD8x zT%L;|U*ah-$dtWHR5uD>;uK|?f$Vy{z11>6yrn2W*Ofxmb7A;}9HS?+-5MJ6Vg}ET zeCm~fnJK0g!yk%2n)M;X$3H)f6ATSF_)ym|Z#Mbt$<}g9nVWN_bRFHk-zf=hhN_)qt++CJ{9rZjk~hCqi~-FGIve z5Z;96v2U13Y~@R+!6~H*hymhQRP#J7V~Wg+T$|kCr*WWhje9a)@*UL=evHH5;*+)j zMQ_$oqVDZ$H#So8k8dlyWLww{byuH08j*tAzm4eli0P}K z(7yai8OpUd$?FfoFFBnjn$~;*Eh?qOefQ9&b{BkP|Eb@f&`ufZsHX{4<&unn>84r2lQO>W!7p@Jk;;1t-UXsMz-+5nkU_SHcQQOH8qqDNQ51uHRbEMPZEahfK!6_E%Ui?@5&O2tRsqH5$tMJ- zSrTY5BONkL6{qvw?ZpRaEm`^Tke>10b&@X)!lZ#2<2T(Eu^wjX6IVI`j%dUJ<3bKe zl&3lMUU2#NV;O_^=TD%5`PDMOKzttGqaEmzR%wWgJfm?B&` z_%wOi4ZdSI#=U|!VJ&<%l#H{2+mxTv9$za9n|f(ZA>!P*3oOwlxB_FwRi_bg(7`fn z8a@?Vj6u@E+GMCXlSml}rN)n|>w3)dZLAP#?HY4o(;Ndi&ONeT46dvgyr~aED`kEG z!#_EMO`op)*u7YiCU#BrOT?SPfJFdb*UoG|Vbpm?LHMkr_vf$$$w@Y`-OcHv5sEk0 zK>GympRsIRpru$8HjdRv9bFWG_?wVdhbVm3s(>Au)5uP_r0vp=Q*k;GVxf_$qKDuh$DP1 z`nyg;O9V#fi}U&!IVV2Rj&WS?aIKQe_zyI-3#mS|jxujOj2_L{zSHZ7xxk$Odz0(s zzGAayJ;iiJzZL}8dMDlGZ z_9qlOZ35G@%xY-P5e^N>xLBX|+2Z__^!BN;A%`6k%srBv7d|>nf|Q9fJ>%rAUMx0s zKN-anNnN>toA%Xlzvnk5Q0a0}K+d;G#|bZtohh7m3iyVY%VwS$R0M5TyKh&iVLTO> zk2FvsQe$lwMA!;Yg=x+u6c2E{Mr8+A?4XNQ4n)aI{4#4wvSl0}n<<99fSdc# z{32e!!g#>#wM)OmKA zrnh{vAc6OS_iR0RQMN#_dg<)6ir@Nc=-n^dHpvOJ>tTHt3rx!$n}~_p5pqUb1#NMX z%q6&wbrShpe5-{ejJ7H33;MZ~U$A}KJQn(Gh5EgFjCXKkh9m_x#fPh19$;P>^oU`a z`xf~8mL~O`D;W|N+Z!I}{ABdw8X zke!ZUAA{;<95(VMY_^yV?xip1=BAcJlpQ5R_HZCJXfy@tMp zBL&7_BOhA=m!G@1Bsj};=06M(ginC;l=iJLdKgyW>!Iu~?kSx=QH-bbN+imDJg21) z@6PsKAxl(t80)V)kGK%1GYgVolO9#fRGn`PWhDtCycG>W-OR6KqFt&rnGGE}9hMfH z(jT;hWM4@N#BnCUCgoV$g|LBS&xl5}i{#cef#}-P2nUL6{Fom?c?h<}ZH|z|G@&rR<{M8mlHYd@@Gw4&y z4OXXBEcRK#2ZlSXywn44rMGHN&MK;2;|L zmGoFCc5k8LUQcF~HP}TUoU4dMPE0jA{{91^k#^g5#n^az!4wiYNeFV-HZZ`v@(aIs zi+N6W>qp{t##U=yD^#d|H#lDO=)f_rJtCGHH!iFnN{j~@KedxhWG=m(d96CCM~-AX?MKjhBOKmVEH@ZWyWo`xht}qthIv<$ zpX<#>?C5q?civr|Q)~6Dy1D-}g1w0q{Fot&d^Y7-0=j!MwD*QU`8;gjqg#HttU||; zg7#K}m8QgeYO$n>(KQG-tWCt5tjWjIIZW+A8z7HrT66zNZ|c?;p|bmPkg>PRRv#?? z=&e{1hm|NxA=RvH2wTzGrC<=Fd33{+uM43xkx(xwbiM48c|7;(@h)6AI~JVjsDv4< zJ%?LT(ydyK_Fl|eOlK-S5KpxE{r!4tPMVQP#>U7Bre?8ayh6$B@TSouysf?Nt@!w@ zz;B1Laurv49U$i~LKEINuUWr{yrqoDlL5e<$1oj;I&OWE&GYarx_hQAh-F+=+N~!G zceTTnKT;D|RBZDhOXpqw1DI}&{2HpEVfg!goDfqi;PU7_Zl0l|iW#qj$Up?Do7MqP zJEjBR12EZW^5om`&pos5u~d2FQDS&UB-=zYmSsw!aJ|Z>s4i!%4uo| zP)q_%6+)JzmZ3e3onAiGpx^Z|Aa8-hKqCK6JG@|qc0Dx}7(6*Iy`# zWlr)*K^ug4h!xpR>&wqy$JBS14z8Q7hHcfbvz50*AK&H94=2Gb3EyHJ?QFiT_l6rP z8zIa6-?`;0A$lEFP4N*%i%&nGwJnOCR8=yh)~^tuR!uMZm6!+)%UlehA z(t|kpwSdk6ZkRlhIYWQS$^o8{W0i^3g1Bc*fsKn6s`cv%*kl~+3w1hTU78*6$s?Jp z3=Q3mJzT2!U;Yx#7LID1Ka|~{N~|wQg6nFYeU&7|4}w1cU()Y7>&);T+2=qvnUX-< zZ|}>bUB0tBQjN!|AQ_cyp-@36h_iLN@}~#_?WIJRUnIZQfwSbsSsEZS-+p6S>5~PCEt#^ zC)YREUr#8dh)6y>y~YQ-2O)2CFBd7T{htL zd@eBj!0}md9#S|wmac2`DE9#&O|ZQxH4pM0UKHl5)=l<@!NRKrMEMu?&~2`A*xYPC z_zM<`m9I5*+89hzQV8N9B53%z!Z1#z+AS_3)$4tU=U&Lz$Dh8*7~bg>zA?F`MKNAs&R_E3%=KU6hxW07Jc8VDeTZ*TuXzWIMZzBZ2kf5=z%&&b!-@ZTd}8GwA${|DrYEOHwH zfe@glk7YIg8~o$^XZ$PtulVNx;GfDL{L@asw*3qLw6ea6mY*`!lzhmC&aT_G7t29U z8|<0$YiPR^2;(>&!<}uQosutK@;AsS=V23#9ROP>IDHEX)aJL`Q}>@LAkbsDe^Rq8 zr%?F&Y=R}fvVC@>FMKSv`KIrH(Ib{vc5hE~J{dhdug~YFcNCI-r@>p0Xe`cLIA%YG$TL3dP&-Q+kJ-hR85 zF$KA%TK<;G0&BG}VctPhDwS9$uo8{YbT+y}hSgNFB>8GmVD>l<3!;!1)#Nci6(hAt z^rwosj-tD^z%&-xVIN?n;q58zRoKGjPt*v`bR}9)7S?rm9SW4y3#;39T%lsI%^S%Z zIu&gVS?w4O*$&THX^O0WX%t!aVmPpPM`%r%>Y4L-=jt|hu@K_a2dANu^%phEG|DFFh z{(b%n{rC6}4)CAEjBV2Y7XO9*BmZsx#ebpyz<;s;|2_D_e_C0?v?hP4ouXpnoO9*a zu_i*tg`WcI!{rmvNc5h7%Tnim2frHzX~GmniTn5!DELW>zA6Nj(@P}uGyN1iw64pI z{r+bO9|3@OEnSuaB)`AxL{fya-^B_Y>?+h6EBu-M@BCut4f{&YCV;~t^(c%!r zKY3ELuw*PSuO8v%=jUzlX=aJOie|lUu@kReJbGcDw$N)9f+Fh!6eRFbz?qgZD&5|@ z>+{l0uTUo?x(B}ZAio<2tLdZ`i>YGcKz<@Zk~^@XT8q`x4Yg9NgnC0phdM@%KTSn1 z3Gc=Ox@JvOXxe99ba+U8Kyatir`k$q5|^bFvY28^{_s7-bW4hs9m=|Fj)kxv56Cu9uQhO8#BRTZpt!B_VVH&iqkMT#Q zPjI%YCoti)fpXFgxRwE*DV@h7i;}RIfNgtz|7_GRL=IpdoUhx(`<9D*yT*SZS%6+$ znit2fLU;XE5of!ce(CK*2(>t)fwVwXQ&J5`R-j_$6AaLm1PZH=5!ZD0G{$hh;* zr*SlKw$<1VKL$`!fiEHvea4Z(c;qU#2s&7z1x2AizQ74 zckLdrED!L~QlA~Ur|F_UHxR3jxJd-7=Mxo8xIz|#Q)MW$gXf=H zS3**E_RuTPdYD|}+=zPeEYtnzn8nk4T&WXPv3b1qBP6oi{p)BfPjv3-iW584ddke( zVfFWmYAKX4PiXc$;=O(5fRSDGnuh5E0{H<8G4sZBobS1VKg;Ej6pK~ET zg6n9CZ93E9wRa^uny|M0-t&4$qASi|;?HC)yVxOZvc+i!;P-S*E-%9s+uEMBU=d~Y z!X{(@<&3%hGZkY{y!Pu>Hal@P{3yp6)TSk;zCA6 z!;KU<-^*V2kd2|4MR|3in{p9jCc?B-Smmk}nlSeF)<6B^RBBih7_EB|4d>Qm*31I7 zqk15j1RGB%xsBIIx2*G&-A2|Kx?y`C6_a8e5&tWE`t3XDFXpOX4uqs~+gX3a*bXmngS=1ne#;2T!KlQxqq(O+0v zJH!7y|4sZ`{I~P}!GF5{#D7J9WDCx#)k1|0V$b)BRWeYX|tR4B)?szxb~V z;J>?wKicPQRHrqog{Oa)a$FEEYnW_czi}@D^Wrrcquc7@v|2bGnE|EhJ-(e-%S%LI z~Iw%!iC<0Lo3ErNn0Ry7^<%OYoqjzW4OA`y!%mLaZQ9}S!`%wo6lu-x+iO6qJ09NJ%;cuqYwW_c~ zaAS@mwkt2%G_;-9cNqKeTV=2*{xFD-{;^|j>nG)#PW;<}!A1t493y7bQXXY$$gR&3 z5AI>4Cs~~wrgHm*tV?W=vAAH~fWHHMnCxkz1vR!LaAAhDy8S<_opo50+urwQh8UWm z8-_-@8)-yDN=jO~TR^Fyy987kBn0V3x&ah-&aRt8JLDQ4EmEmN|&!?$5@#Ke^nl?6mAnh&Y zR-BZbVBRguA@kLDJd8Q=s)~VQF4(BAbG0C6Qn&jNrfXkRFp=`?U?bXxAc%mpm;B}$aQl;JNVH61D=AfmK%yv*t-Xw(5i&hy_bM)H_ipNLDQ4d^pQjk zn)s!-(m5c4Tn(M;F+Hwad{^){ID>htC`~Nxn?zbONzMvO(WXUSXM!8ZnAMc3TarE4 z7}t*H`GXOrfi@Pg5641+nTF$#xxS|yf?GL?)Lr~FI zeP-&e?PmNlIUj$l!>bI%A5mb?Z%5XWs8~a7B=EbtmfPEttgH4G2)&ymwwp`Xq~^)I zcDa5+8+)#23riVZm5_ciWUogAIGnybmc=XN!bX%66BbWGE~4zg3q|bSg+nZ=jR;_P znIkO4{ve2E&S(~QzX#2;0DJ)7C8JSf`4ny>1Q9lkLu4_N2Kly#$p{7&sE(=TCsTq; z-zs(PhKD;mdRaHZFcl@V_ zxdg2Z9=1WzffD+T^_Ys!+=W-4^glz5AR}`CSY@FP5R(rvI>d5MW zWY+@!+(}y82p;^Ov9X~&t0@@T?UklyAZnu@EsQlK(HPpMV&n`~qt3G^YhU;?&fya& z7?U-{7%BSCi3y4YNj~)nQh8duRmv~9Q+>?WP?=JUx(X=1YB|&sK=D=mrTEPLuJ~sD zmx?dwZ;G#z_D8#`Ai>OQ>-c}8_{{#P_^N&@KCk~+@y+~Q@j2cqzD_{##VUE!z~se9$l1x3%Pmpr`$gFnQTHBJWcTwI*UtFHKMP={ zDwbe(%vEY8)u{=xCzbBAF9NQDZS=!%Q<%|1Y@)vyszRz0sb(-^OV<6b=RA6RcKZj*?Lf}m+2 zmECOZ_X^_AJ>*Lz&a7eXV*Jcaoe=vy@ zbVrI~;MZmh`Cu&C1Tsy_xF$(;ra{^d^6*9zG!2g)8UTGrzD$&vgk3K zse-xnlmv5Yyh4DTIafU<$I(d#bBlW4hlB8wg(7rc()KB#O^`#LTgVLN+Vn(tRl}rf zP7HHy%v&hyoO&^;=O#PmR##rvZBd#=hw#(sO9?OheSAEz_(H-@3-nFO%()`7@|Kh{ z+~=@BG@F;(+^HuOGgvH#1>kts&^mizXd*AqQ8C^vfJD7f9-l2R|!622Ymfd>W%f zdA!8ZZ+J}aU?n$Tk4@2jRx!m6>?B>bJ);Cjf)T!s5bXr}uI(Ro7QJp%YV4beY;YVT$1y32MAte%twz}D?B!(Py5gu)AGF~7tg$g%r z$a%_ea;3cGTLE>okQev6)<+i-ZBpX#y+dNJ?NOcXb?;2!6-#jCy%&2sgkjK|jzzKA z*I@@t3@_andkJBa{9ui*1a*`w@8S{^Ocx^xR8PePC_;IvS(#zp`Ny$ML=(|%Hm^lw z{nZZ(E8ZfNFFR#@1*y4lf5v)7^ibHN?mRH<*k==M8Wf>7*QSuAV*7!3#N;i2vIYP= zN3z5v1a+5h7;R%EfH!B@WoNW)CVH4J_T;wSx1qqIdywE%%tui4!&Z?$lJ0%*b2!dH zd=n^EaRov6#J)t~^;G0E0P;va@T6A@$4*cbrV?sLEa)dx_?Ga=^NBBX?nB8q z0LVi_{K`QMtls`bM-c5OLxn;0nM!C0Ir)edo~AEBk%iMv_Wi?0?@5$d*yVgfTFxuk~cv2Q>!?TfSu!zc$J ziTvBCZz*`D%xpNmQorP2uP~H%pmO#B#Xda84P+UACGoYS>WMjah#ji6f08Pl>r>$+ zlFP1)Zx=JKaiY+6NtJ||0@v?|gv$=4G?8qOCEi{9`e2r~yiPJPEBz;;=E+>XL3$e>YC)2ulC5IBo|DJs{G|ca$~5%eaF+ zD|dGc&q}_995xj0m1x^Hg8XhjOe_H}33=-ghPyKjNFuuz4h=PV{iDQ&ZvOG*r}rsm4OTJKKg7=gA+tuiYQN0|* z)b8Sqn$O03fsa;L%KNftQq57rjKxwF-pmouve!ki3!*!=-Pj3s2;YK&=Ulg&)>O~g zn!zeUpU0>V*-*}_jE3n+K;=-%cc3kQzMP}3dFNr`&+xpE%f)DrvYc9F!9C-^M_>(( zfq;i-GAKmNkLhF$23pk7WCnp^W>rty!WIUm)54u_a5nwMV+IV)l4Jkty)k<^TpN}i zshtvjqGGN;8V)P>qpmA z#^87+04I2Ivpu1c^idmojVsjv-cj%S@d6$(@e-|5 z7YkF{KIlrJUdH)RYN?i0a-ktyh%&CotM_|1-@~983?qvPS1^s8jAZ1c;=LryI(ogh z&JjIESDPqBfCRyVJ%r!+Ebw`&KneN1grKPec^hIFf99Pqy4F9-VH*EcB+Te4F^Ms6%q*=P+gYx|=t^Za zHUn$}-di5P4k|i1LUyoguZe0jNYwPFp)Y5hnALQCV_DK*bd@zhvB{@qn+ApWB>d`x zw3qTjUKJWa%`b~ecJqrciGabw^!I${LrC-^292}j4hxRE&?Os6_9F&m=EDHtAfpkk zGKlz#IB0JVXD&+99K~@T6zM;d%4!mklI3 z_Zp`=p&J{`>AC5%s;8*+C zD=_Pe+#{+WVQ2yv&#u21&tTubF`mf(E#v9@H^%c+|FkbZ3fV6h@^2W=V2OV)o?X8g z&!+#F@kIWe@pS#oc#0I)y9%bShTU#+#n+?e za-(>R<;~f%t2c6GZ*A(l3|sG1(A5NS+K2?0Sqg#(g2(GnJz~cdanKyzjhmK1$1FV} zMR8jxD!!TXGg&t02p-Z~y|+oklEE{KfuEMGZ5oGtGSjeU)H57dNXlG}4%ybw8mPya z=j5xwrICEBrp6}Xlv?1e79;>T>Lj40Kc6(S#RQG)8{jlr#jdG_!GmWJN&;BK#JR~mE6$Mw#U*1o&BM7Z}+X>~Ergf-(Z zzUyoQKt;fJzsO%#$Ij*B)FiiP%av)QJy&Ek)*jv=rjEU=qgFZE0fSgl92`%l4j#~h z;p!xe9?e6)-W5&S>rf8R1HVE8rpN~_g!vW$dVqsSPS=LO`=SlhaBIDvz$E$amcu<%lCbnRqu&Lxz> zJG&&<4Pbkf6CfTHavi&G^p?Eaf17N*ql@Cs^a0>v z=q+5iFCIibbdM?62)K2=)FyVW$hmlR0NqhjHK&18tF;Cfb7dLz1PmcqCoxPM#Dex@ z)N-*)J9pV?AA?7aPaw14HQ4+9_ZVxB_|@C-lCnl6#c)+v&|XP+%V%qf-<`+hzbGYW zrNL}8l*rw!1LlTIc19lp2X@O$vYR3y6fJ}PIM@RjjgP1@PWSfhUl)tqCG>wr_kw@n z$FL==y*E2WW$Zu+raT~>3K#lJx zkN`kDg;QQ*jQ=~t69yoj3jd0Dj>|F23I2(A!u~Ia=Nn08Iqkm?&+*@gr)+E#tPfI^ z9t|tJLjYv#bzu>_u~rEk!1oQ{G@)bhg%(@!q%H|Y}CGMAe(n(vD%ZD+I>QPKO0NayR)=zU%3 zt!eE{-KhO^yM95F@ZP}Y4qo8=jxpnDst|asMYpyCdZ;p&Mk@?2wksFmH;HHLMgKyMc=|i4hWA3eu^FjebR4Eo^}wxE=CO{j0sGWWp<|@30qua= z!{;)yupLcdg=r3|H{dDj{f4@1Smo5p-O{fxn$o7kGt(QB{OBORc(qpSF1MtOLjjCB z%&(&)$R&d2g#+1d*`L}*eb}gVPoj5%djj8C%4_2QCMr^e-AYUOpD%0Z=Sb`6v9gJt zb`;*HZ|XB6~ZGdg)GP)?uXVJ0Vw1+kQdMV&f;=e z_DW%r%@p>!$2wZJA#to$es-T5J+x+-hRMK9psATspbt;^JMx>+lb%Zvi2;4zHkxa+5G^DYji*!Ng0lWPa8hzkswvcLNbnK!1PH* zJq+Y*UuCi42Q3)HCnto|>YtbkMb>i6?q}bh3>Sea=&3O$6?=t7U^x|G#eog?ZqlV` z0nK;xfe9cyoow^A(BXe4Ja1in5X8R_o}hmao}fPnPn`ckcryLJ2u})}zZ0IFr4YnF z2~WV(2QvE)2+uVbE@%uGQZsBw`BHqxwwhB5xnk^c9zGE&gHJ4+G3F zMXQ`S`Q?3RDi&z3>)NaE7el2kJBV5f4RL4-?ku58V;MtDgG=zytoc?$Wa>QRILY$w zqomysg@<4K#7g$OT~dsQ}uJLm?DA9$UEFxT!>Qo9YvRtG}QOgOq)&Z;o%+} z2B4NE=P>kNkM2G_4l7#=wZ+a|lDg1y0!x|{lggZfHfzYYF-OT%(JwuowNaybJ+NU% zp%0~TYmftm7ROClQPKHnkqMlWHp3M>?+9ZNlER3~?$NfeYoynVbc?6X8vhrF&x1nJ zP8R?nzY8GbR^X~sIY_DjNLrH%@l|`hNPAY{8>vhj@O7IE|C8|qJN_%<*$FV7`~Sjt zngNWb&!3DZsNsKRJi$O|4s`b)jAzwv#&c=P8r3y|65RTm@$3W`PuKsD@#Oti#uHQv zFrJ*wyniyDS9qT=K_i2Ts%1P4Pxgvl^A3F3&d2dlivpz%T+X7p3pG|@Z7B=W!tiSm zcu0GjNisUb=^edn9~v#M{tPS&y)8ZIKWRF;mq3^J80IMo|+3ynnS89&}d zf)asL?K*f&IYV zbj-(lvn}u?A|K8}CTcr6w)%TL%O6ps7#WxMMVTQfdq2qTf}qnEyOECX7$B2+c;U)v zwT364XZ6F@cOwOvA$6SW5T=*3il}T4owoQ!&=+Rj-YzlIVPEMi+!Ui!cwORIGC350 z3}hCF<3~+L)bV0D)H5$7nfcFCr1=~Lp6_7b>rudaiqzc7UM?`v`dUg68;0j5h`vk=@stcKBeoUGy!?}j;1+lPymLq+NG?9|k=o$7J zMlbe!U|ZNGHw&@?{AqK9&#ApCc?^vD(Ai4-(xm9F?#ab_B-8*7)UL!DaPvGZ*JltJ zz9y)jb3Ds1sNWo79HchPNU&d=v&1I8jFw$m1%CH^BKJ!muuM?`9GI8Nsz$s;G(ZOO zJluKRQA>80DMOT(C0eHd1tbtIi%nBHxUy2WDIfCO?cfs z+eQ4e!KbrygF-MPOMKTNgo|Hs$PpuPEJkdNUT2UoP|<8LTRLSf?|Ic#aaS=;uFY%= zyYI&&dMiI*VGNJR_88Tma+Pw;el?DJChwd}P^B}kSj$f3x@|@m0VX*{ZNqiA-Tzcz zdigqGr{^xo+qF_rD~V$b!UT9x;lsfa6;o75TC9mXe>mR)s3`?lu{%zOD2HJ3B%z5?rNq!_>IqV z*EhdH*=0N{v=HRflsGNZWDA;wAUBuy7N?*aWa6s+JJ2{yaEvzCJXuPiC}`&e{(+vW z_290%n5%$GHU5@@JYi6T4*1POZN}i|ByQ2r0%J1W$?Da@467>npUX8m91ZyvVTQBL=vFrNmifsu^lwYIVWJpvWlsp$# z@De{-6J$#xHrk!XeWvv7%vEGyt5ON(%{1@4gJ}Ltn4aPBE*7p-R+8vua+LvW2HH`G z$Z>*YxrjjZG_qw+^19EIf3Ev04|!G!B+H%=`f5SkL=46vCFsj%e8Zm+O`bBDet(V? zUx!a!2h0r zF>GiPEYFT8bDANY}5zfqmNe^8yh zf4KT|7<&IdQJpIQ)fxHUsZKdb)hHP29fFnLR42?K03R?kj6^+56F*zmR0%EqUO`5# z;P65wlf6Gip!2wHJUJD~<=JK}361Jr=r6_}bdVxg*OnmV5y+f6q3Y1MA zCDUpMO9DRhvE!j|8$>-tD_1#dZ)ttg^><;(59}3>ezllC=^M+ePOjbRZ-HF}CiMgA zE|V5dOb-$0Yg+|W^cH?s^nCPYIU2C>Zc1L@s`CMUDFfL1ju*INhQMDnoD666QCkDs zU@I)Ti@V`hiCOnBdL7un0A#KlL7#aqOC{})`o))6W5F(>DR=+dMm$G>J(&Ek5)^V4 zRjk<$=ZzR44soQrh!CO8;Y@{-DRE7q=0g{-4#5+}PpzwZt|a~SxRDmW-)I1B4Q zpOs3%J@|s@qQ67WwU$-k80jLS77?m?DiMxP_+ow)x0A|6%6=HU`oIk(;aBXXxIw0S z8Q0Cd%XZ3W(uk9puWS5lMw+@yYUSqgnZT<%Z~{@Dy*kUrl}16MNyR;wbgx_>Q+bI> zCYB&;7vNfOgpLY%@r1>HT8a-=eN z7xXD}rq^I=<4Ec*4Evi54R1EdM+KXzDJqIqY|QfcQz|O@qm5AQkKvii_X9nC;nwV| zd6Z8jN*OvK{#nR#Li|sKy!B(chn$<63=YCVWbNYtiq>*&*3Vx0L#YqXI%>Yknw(BF z?K@R@d*1&uwng#^huEk3Pr z`?LRXsEaN;q7`~+m})|ohUVMyY8b~U3FVZpoA;Zo}smRbmx%7pA%cdCW#<#`rpc!noHM>E!s3a7=E<#;s?V z6>edDMYMrB!1a*g`vn2z@qMRnw9w@oh1Zs!x&&oN7cdJhZ z7d85({p@S?uJ@YP$p^4!ec!k*Lq4mtbMA!#N2jP=gZfNSr9G8;^6t0$GK@S~x_B|{ zDl#L~w3TveD2V-8No&I#H?L zZ<;d^pg9%m82M>?f4#{cK%Gl0Rf2Dp5)9P#G5}KoG?FDTk%r|?;*G^E05PM`arVR7w z!{dftcYm|IRsxKDlrDg=?@&SrB38B*u`f6yQCgtr!??}KT_-QyS)r&X5^>ShHPxP% z1R{ROMduYtGONA9G{YUf` zPFM7R`2uxiSLh{zyeGo5qA%==F6!HGE+@Y3O$j+##Dr6q+9;XE&nM?-qsJQLdcsqr z8obThM+0vYJH$VYXXD&63OZ#x?OtZN-aT1&tt!09+Xss_b&IgoU}7!axrrg{`o;m3 zFBqQX@-TqQ#Y^+*Zk)Q*IE@a?BTZaa&vZ71JXXXEtZkOFe$q|ufL5^S zlhbewu`6%;JZ!G)&$bJ-z+=k#@z}};QX$q)kQ8vc>9zVMKMMXqFu#maaB&Z!|O`e&RB!OOzfB5IJpAr_1pM^vqHy2R&xJ7`+Ze@XbdURX+521 zF(OnL@b1^b*E5?6t!XX&Q+Owb5aFwjxStxM;1X%&cr1X#$XIT^9^*}NJh-aW!PlDp zAS6r{bWq(E*4qI{4Lhy-E9@d; z!Q?spH^VWXe2&b(hr4129Lr;0)h^-h=`(Mvb{`(Jbb4gXKKNPLhkZRorNLMa#*J~7 z>AJTQ=1zm^J$EWziKw(-`!b7ukMPQLHZbIu?*rD*8+7RaeSWcd9IrTTd#V7ulSPwx z`YWzA9tjbi;^OYAMZ&Td4~_EjZyM#ZS{UQ~>z)&3(X+Ih%mKLQS?=DOeJ#-|o*M&Sc4{#xIO9Y3O=c&h zuh<$Lo9}{G?pF9AVT%Z=f=|jLjAbp~#(9W4!)zQ1 zthZ2*nLwEtPsFT*RN>Z-c02b*-5WNB7g4~7G);aNtMp~>4%DtQwL`_CoXa4W?$0Q{ zcD?^{^06K6@-jiZ5MGIqN%IYb;KO@4=inDI=R_y3_AUJGAGx9z(492!Ps=_}K2vqz zuxwDMqG&bt_&6{bILrer{6VE)|jRm~!|c z>8vC~aFsY3flFNwo3o-Dw}Gm3+gpX*nqV>(6K>#VDKPLe;_0rw&0%TjXeATnZ*mY0Xxq^=RIQ?Xr z(`T3dAt4Mc{vja%XJ6>Mb7cQT(<#gR8ojSCpnfu*EyZKSwa#`hmAOh|AALxwh7IRuL!z2;{1S-@@$ z-N%AU9VL;lrf7%aSWTyx`#mw(mXZnk%%~oWaR8gQMMAp)c(Hc!y3~BxjA6~wVo5JBtt4|`=!E}GylTV?n_!8|XRubz!{Q^C zD(R#I0qQl8L*t>Y*Q}HtB#}-lve{_P=KO{-+xfHyGQRFnn}MY`x7#4V^8Oc5jEd@U zT1oghWl}Vitz4apyGR(StVt(R10s_vFmWt6Ye#ZK7-OU$s}(X7&A1*5$`9MxYsF&5 z?3(T~*nbty<3TC&%=FUnF~+)Bl6N6W;k6l6ivaNnr$kQxO%3;hCN_Sm3Ax4gBFy{V zP5)*2Z{oot2y$tCn#YG3`$3UEL8m$A#%rIah?+{dGpg2Ys$OD;3hWsw2%bF6JLr+;za}=g;u*mux#2 z1oA{{B|o;K;5`Gz&>eWCubt7E@Qg%r!Ji-jT*_O-r3(+ zo!2c^w}0n0MWfDeun68bo)8B=@!)wI(xfoVf`_`ISrbI|{)G=LjmZp*s~xsf0{iya zFcsLM2b^KV=9dAS)>_;s)uOW8j1fMt9z~IwpxQO!QIxyu5-Ku_>@J(UGd7}6*PM_Y zl0VBlFfzT@-X^8qhH3P?r^74TU!Z9xZ|}JCSD40>RU(PZw2D=m*{ zHek7=mGqR((lOSQry+#wM|h4-ZB(C~iBZSEB`J}qr}jKWe;n%k_nYaW59vz;zn>X= z^2Yqbm)9fn1i=ajtuO93fl}=(z1!Y3%Ds@Odp6(JNCej`swzE;4u}=9J-CxZLOh!N z9^(bwu5@>US9n6`#FLSqgqNg}HmY3w?_2v|ZW|b_VV#C$`C@&&H7(tHuoq>7-lvyh z>aqk`&u2Wc;=%BD=wSR2Laky*4NsGs3Z`Zy8w`QprBBv^*+k1{_NfcYa`(d#vfoEb z$G(1x?)c*&N^aZVm8yya$hd%lijK0st;Tq%@gpNw@*YWw) zBl>xk*FSvnex4vrvqoLl$~zygfL%odpbF_S6B5}i-6(mBrbM;n&oz9S{E|wcXLoV@ zcgiA=Fo{_T)MeU+d$hrmg>FmfzFoEb%_FwB*766E2|+?%&l+E&Z9tU*X;BY^m)w|L z@n(G-4%)98MGp|s0?FF*kK+yd45g(^SiyoFM4k|U5fw8UGu#eC#w4AtJMTZmM$0PA z143n%wx4J;RrtMJ4GWeH!e=;wOG~&W7~}n3mKra+5(QGVNKj|)2D07e1@7ZUmxTp; z59QQH$jt`r6Z~Z(vW%V|0%8OG!C_o|SY0qCExC8t**=ok(iP#8xAFoS+DA{1{kE(9I*ZIn)lzBzTV? zfCbE{B^h_^Zu#+;MkG1~a^xUZWY=><-CRHINi!dkY`+&@j&{`>@MBnZc3>{Z+4LVd zl$qn+W9;WuzK+;5sD%;ocy%~zhd2EwZ|pTOlkon0)fn&@D9Ay9jyHaVV@bL?gKk9^ z-YDk3tRZn@oTUC_$$?o7P!`!5bxiYgULA(5Qhrp_7o#^f3{e_~iu*Yy3>cEsKs+Dd z*1t-u1qV0yCtrcOUwzHDqC~FHV|Bh}Zua2#q_3LG@hR#V6NFYg3DcZ7YacSEGjpsc zK>{S2F3eTlPRDUYMZGJLuba#qi`pr^HObBo%@zdD-xqc>Qzb4{hQ5rEc#kETa8!a~ zIiCI;=Dd#cDl2#E;ILgSkS&9lmdY6%Q5YDo=0EfFiKf&Mr?MF9?wGadzy*9+je~~r zZqi}a4{_TW-un4^0{4oC0Uy3#414z;O|2sbYn+Aj5Hi3_n{J_a`!gqKo+qM~)-SX{ z;wkc;%2eno^6M}oV~AK)?Y(9E<>@Cb_JcOdqHJf6kc$H`Fn#K@txU9}%bjq3+{{#W zDI!@;QVu&5KShW1AkhqoCdyu??`Ao&zTY7?x@4|xbBTtu>yW2?zl93rL#HGXy8N6z zrsEfr1BeT@3I-A~vroYVDocSm>1kt|kMQIzOFyf}2M<_{;+#vR9OIjg(6ZgfxW+^& zE818+A&iv%a#LT8hn0{LR|`d$hGS(u)@bK+PdnmO&Ny(4D4?6a5pIwlx^7zDcwX3d zy18`m@Uv|I-EW9Rz;$D&qY5W_XozYqq5E`+*b{RBD?^X&c}FY$S~*7>2S0%dWyGTA zVpz@YGNT%O=!2aFckF>E%On1Qv30M*E}w)CTd3y6uT_DtHzPS?*sF;Y1$e6HJfe{O z>JK95c9o@6CJ!&-)0UwS9uU&pVahf`JBN?GOg^JsV`{F(+Wxq1=Lp@@{mOG0-1_+L z_mtpTmVLJZ#!!>kdE`%o44Wk2T|c7Ak9m#%Rx)j7DjodRCz;>K!hU=AEkHK>CQ@zS z`0hMEx=y9@q{Xn!_WD;em&^AT4AFSP77;7>9~Vx(n8lLvL{Y$R`llsvmD2WWNo{+y z4IrB_kIxXELbV2sCfijYqYcG5725-r z4WLUl7SuY{S3bFzPxoQ<ike{LkD=u=y~YhuB}1`9HfhP#gQr)BqB!x))X6D6u@xFVza({zI`y>j;WO83H%K8l$ zD#P-7!z{s#I&^EK%uKreI+22o)dBF7L|?=_X)!^N0C=RCqGsM?}Z#luL$zX z{DeJQ3-$@_AcXKm67mOqekY}_aBENhz5WS#qE`TVbMsK+P4HVyGM-4;?-oSdg(Q(W z3<(~ck&&Oz=l}*95??G@MxhZ=zY_V}`K^*(!T8nkCoutF{q%cmx&3twy6yG6E9i0h1E2=sh37AicrISg~S+!3H@&ev?U+puWjf&D__I)-ZA-A2mr` zXfC3oDkmp{p!gEtAdM(=O*d(sz1s+`-*<4{lSc$3s1>n)VL#Hr8zmHp2!fXkgRu?< zDiArGCnyoN^f~DBDQ+xg1jNE-Q9+I%RfcWdvG3(S*$HJS%q%o96q>+Pk;o)62z{$0 zZsQ&+yi z6h-TJja85sgy{&o8KcE>FSaH3BP{r+PwGRGnbwaa#LiSJdVEG9dcM(fZxnTt%i$>R zM`u8glL9ZmQm!+C9x_=EMt8i_im>GY4-8Qms_!ILGsi{1|~|-!I`2+E*N?aX?h*kPngS91Po$w z!U~5nFDXn+9b89wE5#1sqK!=~dmYr<6uV!f{qP7r9L=6I(qARo%;*wzg$xmQ9}Me$af6O^--=?NQE$i*N#z!|3KlbLrMm{NS@)a> zp>DccJbq%6n2>=Mb|2{}f*rF6-NalydI1`k#%&BF8`z5*UvCV`q*ue|fm%=W+$r(o zYMHU*gdVib?>6K1J{z@gYmu2p&cAjgAs5J$NcIgm2=agaoe6gOHEzN>s9L{nWlYBO z0~1~EL;jCCP_|@z4LaV(9L^|`XFenVO@hUA@V%ZR1|MMS|9Hl%e7~0*n#GbIUCffp z?y`Ujn;xT4=e-t?saU}|M{&(j#*{*-d0)+5MYN`Wls(afjOEMb!b3PZ2N=yjGZ!~{ zSDqnbg)d&tLl31avI8jxd@P(FLk6(>6ua)pEHVizUgI(y#d%m2I$R1sV*^8AhpMFn zks(ShW0o+7th-hLiTPPmR`RmwaZy&Kowm+6_&AXB)Hc?AO!5Tro=5z$Mpn|Vh@mTn ztT1V`vk1qnyTEZ?rZKxR=-;j|m4mifG7bog{WV% z^7iYRzWO_RJ5UhSjDX-Rh=txA!JuV@pXd2_u z1EH-AmOGh~A#$qD`_j%B@8=J`5{DZi-2uu#}T*~|@234V;& zjGg(qL9@E}y0S)$VWnlv$9ln?&nEmR*9^pZzvHa{St8%Nw$9OX2^xYud zW`IKCWeYEXi~=sL<2vtpr}k75ertE>HmaD;Vn2H#ku7}(Xjc2~$Hq>26*kB@V_ztO z26b{v>F4=K^cc{+HeU7h%LZTYY{%2iE6Ou8fq8kJ%W;8q1&>|T0X9Cy}f@l%jq zOaj9aV&1VA8jkoptjgJnof%O^3S~j~XEW4%X`fV${<-$E_4u#N(483W6?tyi%Lxzg?ltDwNsMwlp#$L zX(Ep*Q4d}xtvrYIF0BQ>XpwEV%Ulhl@Bxl^fv1Z_?KXCurTbWLMh2;6owdWghd9a! z;LrJIB|#}f%TTyNrg*x9y$J>4P|-P#aamTL(mca1KJD78r4SY%A>BwPBYg~gv1uI-#HKtLz<-NupG5@-ki5w)Il#Id0^_iT9mKl+6LjA6jN2 zZil3~p^s)u7fUh53+|V_G07OZ@QT;KI~3kHV2qTYQ1z(t>-Q}SZo<K$qbuoAhN z$h?PlAj3V_Bon~%;4#{0mgfr>*`|3~MbZ=&^q6gLq-#xw#TvC}4P(`8JB3QE95l;H zV>ixH%th8GbRh^Q@##7A2Awa6LljLk;?cXAzS~iFqY0=h0BP@WA=n7s`-t8iT6ny| z6OeQI1o>-bmF{1i=Pk2ufT(e$=F!DV;TiR&34Cs-*toui5yq(VLFFOrQv&~{U2Y_Q zHI0J9IbRO=iP1LN%Gs+XVrAw5rZ*(lji8{BSp_1(Y!$sb+E5v4SvMNwo0J|nr7N2_ z5&Mz_Es{Z%5gw*h0nT2dZ{ZVV?lu(05gl>#U?N&ue`C0YWmYhNA0~U1^|{=zD|5Y| z?*ockO`b0f?1S0`3So>n$WIWpQ&GEscaBd}e$D*^2|cgAk}&LiZ8Hx?55D(0P>2w| z)7SKCW8HP=Q-P+gukDHL^CVWP^p5PsrJaZ}uP7ITBl^+0)_!9%FCi0Ry5J{~mi!2J zz%+_Ch)aAu)nN}jC4AW;(F{T4&n@u6{g*sP^fMV*tn0390u17F6$Q%^WxtN$Pu2P-+f;I zo!~pzIl9*rLHKiH#SIrcjt<{WjhZ@#1>Cms6O7-P~h26shto5L%hwS5cLFkAK&<+9> ztB>=zjUYD?=bWM{@g|85c>Ry%I3%ZF`sMSW0g~yjV+iGx&%-@t^P6Xn9>|>35Iy2S z+f8}(m^4yNu(}y^;NHB2$5gAwi6%Fhvn+19sE%wJtbRWC10v z&~@m9sqrb1U|YFb@Fu5iHM@WITRg!);nh2hn8)EvifQm-g)n^yKwO@1*TMw!@RepK&S~tOrj`_vW2t@sL*ns zZ6ImogGOLt4%OlWDe`Les<=u_R4R5*5vB9l_`snJVwlY0;|5s1w|^j})0+DQ-QnlF z#ZJ@x1J5oo`QZ9KRR;Nc`S>jfq3>gYlQ_M$CsHXs|$+XwNB^ zXxp$;-)ZpYkE&dhnC@w_Uqv8mzq)CjQ}1ggdMLBYR5|;SeZW0jJC4u(G&| z?pFBv0aW?UVXL!hP=A-t_xv+-+Zocx5)LPq4~iosZ27d-Sm4y85N^> zhm8;L>;J>lSI0H|Ms2Sc-QA->x*JBQNDHVS2$Kfs5~MajT9_cxsiYvKgwzm_4gu+s zlrHIdhrj1}KhNKT?f#zciR)Y^?)!cD95S=mUj<~@aou+el)Z&yvIMtyETgZEcTE76 zsb~q*SACDA0d&4opOY;s_qDuhB-X`l*;HYdsZ9^mq6zsM)x1&&0C&jy=q(Tmc{BsG zL%u+&Ciu<46ubqvk4XQp9Ze0mf5W}6=vpzn$*C{!s(91a^w|?n>|c9vgTWos9h z?sjJ+Zs+uR)WP_=;ePj2NYPfYh0%m6$K>_7#BkB0WiTAt(B8QbC|`Y?EM&JQOwdZt zFW)n$B4G|%aQUCZ}HO`BXhKY$UTX+L+{ zy_#|NL+V1D+*)3ch&@%&OspgAuSY9M2EF@5oM5(N_l6PkwGA&y4%u&*ambn_U2yIC zgw7qCR`-{ysh7!DdKWRUNAbwu&J?BKLDmdi24ZLOT|jn_(S{g`%8b+Di|q3^OkyJqfM28;lt*`6{*d=khZVqCU6 z@DIS$KRj|eB>BM1feFl(wE*m_^$F$84*Hs_5gSJmv^Zf^{LEWq^xNV56IQ(>;e*ok zJmNaqbX6I|b=#i~MaxIE<0r;cw-R_Qun?HHg)o5-ZSJ; zeRdorjr1jnv_ty+_52p9pcB^17|LV|(F_Jm_WSZwkS=HDsaZ4WYZB<7BvN^#0PpGU zwk)~5Sy4x0NU6v{x@_cx>RMTaiYX`2uIjWRyOiVZ3w8?SCT~I51z;IuT1bY z4jJ~j22t)UzJ{aVeImPRyslMBfd%;ke4tI3w_V~bnDDsWGrDU1gHxLZt|hR1`oPpHl&sLR@wq6NB zn9egXJoUW_W;eru^^H_Li9G6n+8L6RcQLwfv1xTaw-TloRN%5@=%!{)=GF^y!mUNA zrMnnd-wHDHz6(fy*K~XS=FbIHy8D~{18ELs(xLS8j{q9ynof0x`3 zSz$F@3MUj~-~FZy2?Ai%rNXGgGvin=yD<|4#MvVZe>AD5&4g*~^~e*yia;7YrsqJ_ zn7vShnhDKj;lt#He<*^wp4Yg`BlLx)fKvMb_axj6+|0@a8HRsgZ-Q79MTx6FTZDh% zC{*>#5vv+rx}6$T=dRIZw)sl2T^vsrqYmp#^M{B-2!{aZTiOcFambn9<^SSssl z${8YkHQRr|c9O@R8p1i{NINAV?!`{jH^OA|z4=*sNS6byB_o(N(>y3>LxeH1XDicJ z!~2;H=OGlaWQ*%o6EN5X5H%qfT!Q)Z1p9UZ4>P}JHNjqWJsh*ggvNhDiW#IrWsGkg zCf%%27=HbHpIs;i>EVVvdLKqyMtlDA5`-GZ2tJ% z_G4KDYeVnXfPOzWR|oq z!Re&uStCCEwS11W*JN-(uyTd9n%u!Xpq{Xk7x%;4I?22yx}+auGzgj|pJluyp=~TT zb)A^y?ri~e3BYUye`YXh3w`gC_o*wA*}ucEJO3CXJM%vn-ag>UkQR2CoEYVgwiXm< z!&5xcnk1?vB!w;CA4I2?@j6O4^mKIR-MKPsN-h=OHH414{3~{z9wJN0%kjGQY#k%W zF9&RQm7pLCoSpE-;}pX1B=|DA8U9Fx{pbOXtzlnzGFK!1558l{;wPc|sgxS8aN5QE zqF;a_CE_AmKoMQ5wy>~t=g!}E^w{tT>vXdf(#G;(dL`vEb$ zQ8;FXA4x?b29;4&^UHiSU&9jdqB)G4GsJBlVi(e=-`w|7-!=lb``XRd5s@&9d*C#G z_jjc}cKNEBzY?T8Zn?|h<~}>7$gOv*6Ki*en-r!m<2)|onJ;@c?-Hy?bOkHyB`C5U zmkTK>E6~KvtYtf$psGT;5R z%>{!^&Mc!N)uDX3^{~E>BOq^crOo#S@IGqEVcd7{F?zEGjZZls{S) zcl1%;bDb1w6$rTz98QJ9x#s4XCSTkT6x>Hd8~8G~p&5Dl{Y+tS2W|W9TV|oluVTZ( zntGQK=3<{r45Sy7>Llm;4Ba%l6v~Ww)yda$OSsks4!V+~ctaX9s6$AyR|^REpwx0@xpHH&9Wn6l4^i$3Q5g$(m8 zv7j0IP;=C9aBbl%?Xu8kZ#^8@9wo&!2Q+G6c9JJl0rqy!4`VkLKLfIj44{hvm3yR5 zquRqRy)oZc>@gjA(dX|15Dnu+na)b%-?@nK$lkNS(O)MpWGt05p#j!Yeoe6Z4bCIG zp!%^UnFh(O%7MKtJrxr7lB)ZtJ|CV!#sxX2O;cEAe=MZLen5kw6Td)=v8VRKcH3mO z0y?fL@u`oY9|t{` z1o=lEfd)fopFHgm5mBzll3OrYH8`gYEPQ9+U$g8UR9tdhyckrhc%7T4UUKuJ+VOK- z6G88yjar`Y_dLcJagHU#qlX>Ke;o9`at=>16Bo>6XxX9p4#e++m8)3&FsIJ~KWOrO z^&bWx4I0Elb#TZy+O)w6qYrVnIy%SHFtCx%S9I0|_1r_$rIotXGb5O0wrlTDb4=*+ zp=e#;wxQMqfOKNqC?4b)62N2*4fww9B3h9_!bBqDYeKkO`T7FsRU*UZ{%jpOq3(cV zEVJw~qD!0Zu@SR-7cY$!NoLYWSdhW)0@)}3fHRcvwGSHiTjvwrvZ>+LE{R9Br|0Si z#OsXO17&D~U4U)q4#Gy}$qEUizPm|uk95IF9773s-=k=TYa})r$Q=3Br5OrIj$Wpm zVYy#Pn+p5R?1eiL*W^dD;ju_O!=E!zoJq|J5?HnYWKpKavjrG77M|gaJxi#OIHrMU zD*pHe+8Qq_cfc_a+|`oWiy@e%)Zaa6E-^3d0PHvL+Wx!E_UVr$|-q=X&Y6CiPYrnUqi=beFk6cq4o zs_B6T!3#Z@#Z>;`!fiS%Ce2jjaHNZa>AYRkPm zz%%Aw3ri$a}ojn>p z37#+gz$f^~^1exD!UO9k!N0H?9mYMnfEz|U0as(OWUKS+ho^;lewP11#dC5Rk#mr% zUJ~rsp)9!{ue8EsS%nFBF(ASSZ%&FirCzptMFE-G+F*Qy^$3Z#LZ-wE!J#7(($|)1 zLlH2I5Pi(ApLB%Adh0zX2Yl-LdprXok{q140EVv2{GrF#9H^Em*I7(Sj!_{hC7VAh z>$Amo#6isS2KXT19z_lWrMVycaCi%v&S;8qi_e(w61(lDzX)qcb^_iQ+s%?n*hQ7% zrd4Z@;G`lpteM@u`Sr2jcr>~9lVZu%v4@Icvh!Pe7a9%qDjrqM|AI7 ztjk~73R+e_PmJ6zok17E9p25^4R9+_?bO&MxG#RJ>Kh(iwnStYhUk6CA5n}K<7qZCB2t4u{ z1eUxi?$u?A*`XRYM^{&o-ujnyA%m^2I_sq`aM4UB8vsm_!izwlLceP%Xrs%LZ2@NM zc~D<^*5)}y&Ud7uU<_9i+XM9S@epvoX*i>v!ERAWv1={0<~+m@N!lIo)d}{y+ES$` z+&RUU8CTHZOIf~{R3{dtcvOe8cYPDTWT08h%>AB7<{KT+(0lDhSSW9M=h)Ka$9Fng zq+0YL9@?zs&F4h~2X0c1EXcNsSyaNcmH-TCM z2vw%g@w9ygtG&sv-Sp1T&X7Qd#=Jk)72o7y#Cuz>zGuA;CN-W_TubuDI6Kq|4FXSK zo8L;nm~GqqN6w%EXD*8Oj45PycjT z3-0uM9Y8c3?j!(9=M_X5A2h4*POjEFU1a!#`FbGDQkjMdcpleS(Fp* z-Z3i%{ve(SXSG-zfb2Xi6wfFF0$7&JFEr4d+TTtz(K&wnfUYH%yHin!+3m-+$2eyZ z;%p6y#^rL?TQRL0i+$bojGPO)dJVWTVHEyid+fMm5SEoEo+3klbP+^kHJ{&2aA{hL zc)?Kdb0-1p{T3ei&BT7EaNP%uApb;S2)(Ky`h`u;=j&+R{L!19fN0?9JM|S>Rx8wb zUQqG4^3MJ^b#Oj5`qY+U-0w~d3+LHSZ4nDmxgc2)wVjt~U=^D5h@yE= zMVvO4!NK>;%*0XXh`^c~i+IcF50sNg%q3XmTfH(Q4m4@6OG zWcQBpN#BR3uumqp7lR0qGBoBG1UdFHT~W#XaY?WIcLrF;kv@Wj75;Z(&c#7Qs?20< zwvtg+U}dPPS1V|2WX>A=P|T0=W6Zkq1xCPgQ1gP`T1gqgjqLMi7Fk1iXE1F^;XEGh z?*qMZt6V1>9S-Y7iH!8GO@HBfBPR>xU9il;W$ECT20Ev5Fr!*+3%sd^Qo9f2hF-6Z z(ZVj$8p^P}>#lvY$aqIjIWitNG^&h*m8ZN??rei|cY5yE4e<7iSn0mNx;MTf|NZ4p zXc-B8BwkPrfJRgZB)1pmjo)>*;FBbzYlWX4clog360qfZa7IIi0o9Z3rPC9E6h?na zpUIXxdFtQ_M_@O{#R60l9F#zgU2A>_;x%!c(^Tjx&H~@Jv|eOfW1BimJ+~e&65UTw zQ?_c+lI&%-wg2Sy2nHKB*v&S)`9ZrUl*{Mq)tomq@7oD3^x!E9=8zH*TgXB}t3Xa1 z`vDU0wSZzzQ*Rw=tR4lRA(5Rn>*y%RXs-<5N8&xxktE~JRLzOgrl5z&u6pFeDg1C9 zi8E^0m_Ac}nXu7rar;x6+rMwI$oY-dFFcLQrxPIcE-6<|b0F^rLH>lB%Q)pmW$(VZ zc4fcWs^u5s%8lIRYB$P_E|UeB#XkK1UTc0$m5(?0KjGcJ<-=R~{rAbcDexZ}K>lb; zvc^sM6O!eSGjfhu)C0^zjI~)Dr1^&kpzmefFr#s#))NF*xD@B`Uq--Jy5ZWhnZL^{ z*X&{aaDIerh4A-y7%tl?V~+55Dg=xMyF@-a)JvsL0md`=!#VzVqzv#+XqrFgu}AkxuZMuWH$*-a&)l$QW4U?nexil^|_qUw|CW=I>~ zXaz6On#s9(&NfwG-8)Xq8O&-gWg32O@i{;PzeX0aEU3T@IgnMVhH}Ou8yNaXeXW(z zLB^~67|q8wCm_)cFi$pE(wud4s*BlmS0ITll5MRAP49>7uW-P3hF zoyZqRZ3OXW3EV?ZG4C6f#TguEo&jVSS9B92oj7A}ry(CsDGA9z7Qkm-$>RSEaM8|XPRT~wI`Tze)p;Ry?3cZp~-hV-uBS?z;+GW<@B64>pkx zA9D$NF;F$Dlnc75JvWuLJM7nAcy8G(i+-7+VD^;Q6t~e1?aitC z9xE`#xVSE`7aM*mY2-Hts%H{zLw?vxF~pRr76I0i>hwyNQ7F0pFLVZxxQQK-}wVF3B|Y7SJW&{4n9t&&E;W~zp>%8vkiA_L9!V#puLGei z;tTumgApqG9Q*~9LNNlwx~{QdFqxuO4}Vexf0_04GeeC&tHSde^j+<-t=5qj=dAw2ZHtosPe{s2d9 z{23mxCzt<{(vTY`v9wRv_xyyKd?F9hwe#U!*eB}pN6onO(p~G4TCfUXyI)73dBAa; zE%J)v?tslvzbABPE>2~SmLXgh`9*lSK%Z)O$I8}OzN>Mx?=u5Y{Yh7{jkIcOrf;-o zt_th9(>DsKf#mBqE#bu5{gOKO+E$b6zW&VASDbH5_mgh#HC|rF@nEyDWlGsCP6Deu zWiJIyJx}H8wp*%L5(6uAqnvF$78=3{ED<8TLZ_KI=n>m-0IF4 z`chZP-vf3J2p=KF9zLMr&3!M!sT{b8H08_p`DO$k*3tb%sL=@?4smM46mRK}bfG*X znc??D?~>rWNC{90Mtjir&tV|C`LbO$TY5@E83K)P$%kee;C!sD`|$o#IgM>^J#1NW zb}3u?AHvKyz35$jx95*;iS5*StakD90DDhDj+F(&%>lDS^V3(Qti-bY>txCM& zI?&U=0M>QVEIu>Zbb_qce?4>opG4Pn4MjTjxgWZ{(tlCtJkoeiofG0iZZg1)cdcFi z&3oZ0%ID`#wBN_rG9@~KEQHp+_|WN{zHdAF?u{84FJ+u~r3WQs0W{3&BTX$~zG;5F zhji)@UF&nCNQFb&1%I~)k@+;BE%ebaYu{kEA+GLs&CeCpYCOX(9{tTyvN^;Hcj3^H zECG>`6@@8eV-M({RW7!79Z!3O^#D%xEN)^9-Bct(i*kE3)TokeQ%+-E+PW?sp9lcgI3L3NgNk>{`3M!^r zi#p+FiJK!l0Ib_Gr+9q#fVgQN$hC=V5ts7Z6VFLYu^yK;6l*mKnGGYd6PyU`@(KdF z20!(LXsCVkQA{bXD8)x7>G`ZUqpOu$qhTN4yw!8e4x51HH8+znMDbh^ET|naU{^l8 zlr65H9QBesv0=j9u_>)k2F7*R%|V?MWU#tes6>hI*Td$?lI|jjCMuF$FPQW^D_D}+ zR=D>9Yz31@TTkj|O(3BAe(WBUkZJ#xFFT-J)eEgVnCJ)CF_{DUwF=rU;=lQ9rnr3t zDGZhk=e-5klv#tE-WL_Cey62Aw0x4NfVI4WXr@;CwI9$jUKW?0b@UnS3kM<8x0uCe zvp>liX4V}8$b!eVJ6w{8ZK^5}%u_`J1ZlNVX;&Q!X%CvU073{L-1~{e|#OI+39;)}?KU6l{=1M(qSXw~RhjT1#ii0zB5#^w2R`y3Qm6AT&iU#(5YV^+t-~O^8YdPA| z3p~&p8<*1;d!I41%=nUJbjfEj@0eT*XpSS?f(szRkQ2v74_dOj_CQ;K&oiK5N>NXw zLXMMs*3FaBk5KLQnZ9_;%L7Jdw!KQbN;BwL@9;papnZ+S813B7(C@VGgqMuDOQJZn z=ccM{KT~~$!x8+uiU&9ovIjMPfiI%XFtQj0DKZd?tx*1d`plu7gL?+M_?aT%<|6$G zAGIWYlCF*S+Ug=@`uPXLXAJ%TnkTfZ6lYXhO=X`wl#Mg_g<~Eet7ifQg1FUN+y|1! z78p*XJe9hP&cfLNPv16wa(#?-z{uY$vU=4pve^pH(iJr{++6cX8qp7G@j z1Tn*(wuu+|zt(8D9Ee{y9iGiPqy{M3j|YulIlGLmF^jLu^b7DYQ2T(JfBMmaKPJdj z4t^;pZC`*&w1K87EZB-CfOB1pFL`TeM`NRc7T=@%TPC}xDlE`Ee@HO75);3qi{BYE zuqpygi)SY}7c~CCcbM|?)MK=dRKMzd)!+J1r!$;1^Nz_)m+>ks+vy0R}Q$TpF3o%k);jo7NZ_Ah&a@&D`{U3&ScMM9JBsAcVHRL{&fz|l>mqKdGkitq|5V`5}2LDrd1$AUG22c zzBa2VwLK!a*Wc8Bvv~}xJ{UTE?qW7hL-5JEod&$Fw(^I8)mMI)>y@zaA+OhA>nPG1 zDf_Od4|GToXQmw(9bD3d*qCVDPCCyRUHhS74OJi&BkE&=DTm-c=frzV{mTXCR48BU z6_xdjuF8=CZ$?kODTCTlxI&YQ1l4tWnJI{Di?eA%7n&N{=>;-WGfD#(y2)DNehCJ( z7~t3v*zGB5yqQtu(b(Ncw|=DhVM+W&6Rlbdwd`Twn(9N9bBC;7WOUoiE{b?F?NMLm zR}b^rzsl7ZY`AiT95w_wu=5O)gd8?Lc{->Pnm&C0RaI`s+ezx?C%Hd+Qzx_b(Lo8W z1xZkf_okD$eY6>^+6#(7`5sT^Z-afa7a_K`QSCrS0=A+|azZ=!>X70IC5LXBM})lA z79de=Q(O5;{D$pB;J!e8)@=(W{Gu*8ym31veR;I9yubhsWp%V zeN87$Z;j(l37(AItO4IhQr*=>&Zsaw9>ZafN}!aQ15>jO%e|y_icYC{vl`OZE@gq* zr$}D!UXwz$*Mu%^+pkrrzg1!pwWuJc>+k*x*=>6K(gc4!v{8){WTJOL`!pMMqj6y? z%kT%nbS11bn5-bgpT^{7+O0{DRn~$f9y9slwDRm7EvI(%SimVBFdYrt3wUa)OvRwC zm#Ik%h`9`4ZMMva@NaDx$`TAkzGcz?iX`Zz5MyTt@*2*$lMEbB^63GZ+dppeaa?Um zb_$RmX|^mA$op>@Bfg)zU)^6~UIyV^r!3GUM_q}Bgl~?`R-lMYSUz#e49t0*k>L*LdUpOZ?>f-d63Zf6+Y+rPngsfoN9rA1=5%P+rgvU-N~kxvHE) zx{J=pLHshGcJ5VV!DdZ}V!scO_sKBP-({b@*`0X7Ic>wz`gRjlC}IXYwkF85fXTcE zdJ9>*pbtxeLcfgVXE^`>NWcSi6{EtU;(nanrq#D8fTha55%`Nm55+z9a4GqmXT|sd zcWk84OboR;8QD67eC&*zG5kQ8lie(a;b6U*3BEpFKxA(>ECL})yhShJ=7AZHzhbvjS$KZ_ znqy%$x#`18hfac+3LqTZpN-OsHSbDv?g2=T*B7T4Ii5S{{VYZ4;^Cye0&vY5;cxea7PpH~f% zexR`QhIY5M#;-;9GRYv=t~h98-kLX+Fa`5!;qf7tDRS3XjhReqOya>&;px^T7=hi7tTIW=)cxV+^e+o2{Z;kVMG@u&7o^C>O(&$Vu z9z_0c@c@_1ZQ;c}g7@vgM&MMK7G63Dg4%E`i}2X;edh>yBHqYmwi>umJ}BR5tqg7+d(#Danp;HdJ?;4$>>U!bX_nA*_Blo*)2>>AZ;F z3m~p{L*%Eaum1wg#ml?l3zQrOVD!L625z0i;uB10gb3MPFVd2Koaof-CG+?J)`m10 zR0W5~V=OdAA@=yI^p49uZZs9G&18$)ChHE60TZXn)ldgLb*Tgy$8rMu(293A4+C~x z+t|qg_VNAYa2i#d6tg=Wgt}@$$6LhvuBSbR?Cq{L@7L_y%54*m*eXEDO~}B?Gx9EY z(Hv^~Vrio}N!#OyJJCk6!-DT?gVvE}anRtoT2+hfE%|Su3dZ`Z0RpmURI70#*cM81 zg;{TdHh}WPQ`duuze|hoEksOM2K}9d?i3pt&x78Oje)_Hry1(;zqdBh&uO=;8{!~J zUvsH${?~sJ)td(4b&+lrl6ZbGMC5t~(&jM`D!4&U}y#=Xu0> zYeOruO4Jqg5X*D&E;pw`-Y6Zl{bzxOHzBx@s93-u-Vo3jGLRani2uF%?ZO*eEH zTLhg1Vd?LNbLn3BslyWLP0rJFs>aGQjE3mAdfiAp16J~-a!Ia`UXj;1L{IE5!EyE=Xu;y|iTV1E5g5aM#Kfhpe zzw(>DIp4*GH@gRALxp4OhO=YAc!qK{{`K`ZGxB}71>lszl=c9aF6ApQE;@!3Z{f<+ zHVylJTvWxeRpU3^t&yoDRru2%QS>1kiLl1EUt6)4Hn1BW4u%N^DLJ|E8f7`!+Qq;2 zrMAiXpu)hm!;YSt*ooj2BX|rVhXL9FyFzu9quz$w_9UgCO}NvEAsQOl09jWB;rTF^PAsf|IEBNn?Ws{b>dK+Yl09`sBLb zp@5K8m6*xQT9vBBgdCx!tTvY$WEh>^-vDa+|P==A%$uWCK{jANjo)m z#JV<;_dxe;uGh#?zrV*cAi}Loc7dLI8p0yVY{S%DU`-U!L~h5ywQ2Dt_w-xGRx3b-Rs}22*@-aj?sD z88*Zt?=SN*fc3;;C?ahdYEujJ<&}M^C__s=a7^wfk`;ToNV6iv2hDoi5Zo_{W>>LJ zqc$s9@Xr{wv|u6pYxgwqO25QH^DHfCG~_Tt7Wtb@R@&T330dOn+w4y|s=(m9-41)a zV&<3zia%uL!YncASmg+wo%$T6RMg2l@#q*nmf705Z@@1xb#m14M&tB7Vn5x6>*eW*n~?2(n+02o=6|4cJ!9^FyiI9;uYNB?FVWc zNORL_&|f|)@T0ohf@KU4Q&nz(3Kh&~lOnql6?N!ssvy#tG6UTS{v^d`xI z4r>KUm1xf@2jV2xFw=%m#4nB&y?g$x!aoH&U*on#f3_LFYp1%h`L-=1XhBz`aF5v7 zqT7)kBzEV2O$aZ?ph0v!B*hOAgyFaVKB2H$p3Rs^% zeKDc$lI7q5_>bR%*hyt>i?e+Y-cK`aDsa6x6D0pyv zHMExv6HlGn;)~fsivRJGh8scfRnQh24edT*KFH!QFGJWwFE_zYkrgzx{ zbFYazQ|;1XBXn5Z>^|`1d>qyml*20#T9_;C#276mk-(lLI;Q~>_ucR`?AiqY9?0nJ zQ&KmQ*)E(HU$m_NDTQY!+MM&6ELA`KB(Jc2k3X_ut1(e_-&KU30&cg0Y zltv7M>OU@RCqlmYxv^wLiWv^FvAbIyKF|F=h$<8J#eE}1`cKG>D-O!p-PY12gVhkp zr^ibH3qIcx2xBLINta-$0iIjdTfP_AqjLT{wH)bXwt<;Y&lhYj&+R#+33IX4eFc&F zB0VlaPaH#%w?1c~>O}d_g4O=l<17;&QHirG!m&WWB<|3dEVF_ZUWXL23GLX4TNP3l zg}M-o`^Cb^*jWlbK%eg`b@4iRN{cY3zeg~KZ3mIq zva#%kG=yn?qL;ZquR0hh?_11A@9V^eCo%TrJ70XS5-ONAzuy}EU^NYUcjq>3--Bt! zj8>ypB)CxtD=tucD{ZKTtAIFhSF6)_yK=sASr?$vn&={A-B~J?noM@i!Qmj+>*=%I zQ&qPX$q^l5nVJjIL+W?wZqkKB9$*!#d6Mgtz$T+m>IAt|lE=#QdJJx=RYeQ50`pv7 z>9C9p6MegZ7)lA74oSOAi8IgKTc!7;P(O~JcXEmFa6OeNBpPEjO=6KJlDafHda>og z`|>-fEInEM++^opKy}xpt)_~ZLstY`SqKKctxAvT=KaGi#S#JhCSNCYnCN)ihjsY6 zR($E9mHb72S-|NQi(~g*sw(t1uJ{qDCNDi`0sZO8zXXGHtX+KXOt_BB$l?zl;E%Hq zG&ADg#YH)N_nt;g#*$+>Z4r83iZ)(^Vt4_Z`qCwSjhpB-hQJKFkDrDk3i&#ebVeuA zOM~XbpC)f`@hFfaxAyH{*3BnaChaEs9-ZE}qw&{)kF|14n#mq7e!nGaiM?pF3kgtd zo@Q892(V?T0Ocav_aFOI=?G0Xy-_*N#Zyk<%h2>(;BK}gY}a*D61h;>PFLj7qQ z$mAw2Ip9dHBYge~9nF(B7;-}3U##BBz$M&s1{;w{r&L*WXD3E7Sk@t;i`Z&DwK!=O zE22s{D24Uk7LB<=#iA%%W7qEg2yMV@yO<*> z+g)D@+bA!Lskd}BLDe=)UUC=Hl;O9QsD+68Kd0G!gdy{Kf>(Y-o=R$iH z_a9p0`7#|W+F;XG^GmIYmK=-;(U_UXOgM~jKOIr^2CB`$PJvsCOZ}KAgM+tHZDB?$ zwNSL(xksT5R+S)lU8#R4%axiuzPqoNe_nV?Vcxv4b9=m`LsmcB$ao4dYYTY6-Ie?? z)ddr191GBe%;g0Uevd18J`_o$09zYQV#;}MV-$K(#3xFsW}Q|Y{f5kM_N?0oeguAd z?83SLg&vZ*-SK~jZ%a)rfdj36N9G2JF{#vH)cPWkW3VB7;90dATyhW$Gl7VN8afbo zfKEJV-PolQh-cI+&XKW6^2Q|E?d-T8g}D@nzI;GBqFocNtlO6NLxMNZDp(reM)1Fa z84f7Hf7ixBAP7=TkPG@(32o;$SzgDtPL~EN!yU3RjC`psYm(_leiIfITbZp=*~_#y zR`2uKpN5;&Vpl~eGBwCjdc-ePR5h_(Fif_1(0?3USlHMnYYbI-u57F~B9}%u7Ee`Q z%m5IQc{XWj7b+ernRTq6JaE#wqc5MBN}sH@5^C$?1LnO+5aY&tF)u$!^B`7Wc^*QFXBcD1(@hG!m#+CrsM3#Jw zl=82{@IF!k#w_dxb?yS1aB=(rh^0U9;5FWLr?^rvPcofg>?_39ZJ#eMfTK0qKqXO& z+{zYe*tUzW*kNI8E0RjdA9Kf$X{O#%;QH%ULNB(S0CWtWxL%0A!eN&vR$ytVJc(9|X*5*avVcjwuqqk?X=|{Gs|=ezi(4Ue$$da_D&!TPU7W^8mf#CrF zkA!lp3_fAk5Ji^(yDdauDnhoRBFXW7G+pdS9IAaX8aEp;PnbA8M*ApHZv6?Csnfg# zn^9s&M+*Kfp*>2HSxjI4<5wZ6_mY^P1J6q<*3_2iG{L0sQ&0gU8#O^7-Zu?i6t&FC zX8S8OJ3<4$Yw>jLb9n!t;n1E|mu8#kBxty#h}b5ih0yeS45k?`-qWc{P)KI5vg4YJ zUC)rQ4x~Cy?|Lq;Pn`$zr+#;o?|seA>o@HsOb6`{jT2&kbePy0hYoa?T)7WbZ@H|jEiU_xue^zkFnMV~=YD{0QZkN3FL)>Z$t9)K{d@pSq>`H{%bdpZ-#QG~+t0c+KW>9gQV& zZ`-DV)v#_~^H;S71lM4Zle;J6HFy=@tR6(C!90&L)vfQnT!?;pG>mg(h?zD}#}u*uUI zZ&*E~?+!wYg#@_%#-`w5aE3(*wjg_q`h5DYsjFD=AEkJ7Eu*6AOi4xr8m1Q|#0X$G zX5rk4wsD}d1mvNN2yxqU1h?f!>8Pc=9UODWQ}8Dd0WEWf5vg&L*ZSbb$RVgbjslTc z_z+|n)*b-2;t6wN(7yh^mJ)8Pvo@T21lzJW zeb#9|aZeu*{DC;J^S+)ot7Y>pp!?&bc}k7F`7xCwqm5z?T95Kubzg94UNc8`p8l92 zP;e-47w@_&AmIusE4JL0I$;m4qbGH%Vyat%tbpJ*wCHz z9W&4aZdXqkw@hHtvK1*6Z#_!eeh zS7mCg%v_0O*b(w)1tyO$_9P!g(P}ATnC}Ztp7AU*^UQ=r|4ui`qW;etZY{S@d`BR~ zK+AyMuD4@|f}iiykZLQOO3)L>x$1i;$#?SDmHx->=j!F*+LpUxG%EKV|IM88L5qsa ziT;)Q_>9G$%Zmka{4$QfzcqQ+jM<+>f#(}(`#kQaa37b#^VK>YY&ny4@#`2*)*9Q? zh*FO{^gHR~6P)3k%y?~ zWF_F)lL%#D3sMu|d!fH@RnH465k-Wjq&`Y-tV7pu5?7t7abQ8H07q8nTUno>792-Y z|4;`ko#fX%C_Wf5yoy?_*MmQB&k+k2ax%{tYk<+#Np4fF6YAD{?aqQ$T9q|tIpwqm zXud={ja4%2Es@QwDrJ_P2tTF#uWxgk?_WQ95lDFwF32*B1W#gK!qE)kILl(5pHl(X zLOZyXfov1fN_-0VRsWB*w+xH23*Wsbm>GINx=TV@8mR$P5NVJ`knWVu0VI?ZkQ78D zlr90OK^le-0Rich?vB}`&wsz~-rx87#&N&__hQ}GT=6^4rT$v1JcIZZ74}B0RmJ>V z2Rf(nh{uL?zsN%fUS%wL6=*2Vu`eq7iCMj;6@9j+aQQYJ&kKoakB^zdKPBjvFq;tX zk$e=}g8$vqX*qz=;kU2aG^qH&4AsyBQjdf;rJuhs^A^xj_t5vFq^)hVpsLpFq9~3pG2j>fG3mzCBC&ZX zmnG?PP9F|mupvm`lq1oq(x|*z4zN`OJpw40ebWbyw-6AL_aBL79>FRzemBeIG>83& zGs2z);eV92Joor~`*+Yp;ytzYQsy^n%n64zG(gotpmY1C&6k;B7Rwh-4+kHOY?o}F z(9MUhl{v;6V`N24?l?Sq3y1vp_GW-Xes>@V#siaI8#QQNlT`QLUeaz8H4iA@xScw$Lf&zR3HN(Dl@j_{SmTH; zz0hO`Za$I0b51`KlIdr|O5MSCP1tO=rUkWOw|*EpI7R~Awvy8MrG?Fl_#)o`XI6$& zBdr!7_KN(n3#=PhA9+}8fv^FEM;$%Es1~KWNVFSl7Fo2)IvqNrz=WTiyA0&UTL#@H z0%EJup%2(#cZ%~lrtMWbL^zlA8>pr?pTPj0sWbT0JlE-Qk6d`|Ps_AlYM~zs^X65G z6o1R6=9WzZ80LBRTAd_BvKhbJV+3YY{pqV4mj6&BEOw3Sd+MG0k{HsM7&K#O{@(d< zCU@dZ6`Iq0%`?84aHybqB-ktKRAyMx1g7V6lyQM`U6cLo&yNL$yh)e__o*8=Qv_~2kA)C5~qe$_?&P`M3;yr5rT z{}K&kN>-#u4_A}23E$}3Aq04Sl+3a>pRN)g4r}ZTv=9Tt86qH++1MfDgXg>=l~^p} zKhYYQvc*yY%YC~o4qHJAbOB+CyZM;Fh=+w>Br{<8rO*jGV(e1In!%UJ8WB4`02S4&t;)G?~Em2 zT&uLxAdN)!HaX}%yC+Q9#}e(oAn7SB5WX%&Z;4f|iI#A*@wP0(UuXbAFc!tn%#-)xib`65s97ShoNS=OH;YP{j#c z4E)rT1ii1pHBuPe*@Y@0tej*5#}K{Q>M=B@xzGH2O})pyTbAo?US z@pShON%R(b&q(Akp!esJ&ynk#@~-&-g`QXaqFe%&Y$ilV2@G((!$58p@G3G3(J zoYDD3x1(60hav{4Yf|;lKn;nl{AA{+eXs zts2Ew5N;_dG{sxIe=2k9gT$A{kq_>=4@O#CTY==WtzHKOAr?Yd{&zKUHuOaGy5{VR zYPtfkc(QRLBd0I`_AKAmLzHCCbhWN&3Uci{X##-YzBa}sC`MbEA-BZJ8U7+r$spx- zR8Q$g5B-p-zk)&?yT@XQe2;KYx|z~PloIRTX8p*%Lk@~&6u5%E$$A@CzNN%#7jaKmDy|r8s=$V>No*!j8Pdyoqqj9YhOV{&~O=}X3(@qNoIW^t5hs)HCSbx-iSLp<08l; zDMI-HYgUaE0H*k6@X8`5!HUNy+P89fjiHDMXUigQ-&XUuQnI9$Jn*TB>oGy1NoyXU z|Mw;PFVs8IcBVrN@2@2+ho z`{{X+_Dy(u=j)I~mcAHFog`axP2NxIS9<15f81Obqi%6U{DmyjIq}=sn&ph=!-CSs z(^Ac?m7jjbaeAx?(-lq|HNCOcQQ0AtK1PLFd9cg7wThLXRorCV_*$bzMhv8UM4YZ0 z*M3hrl1Q{K_y0z%+n99&-!x!dINDq8-BLe%*O?~qzP;3j{0I?TkWLBMRy!?kHN-aX zZ~jqF=^2l}8~7Yghb({w>lr5asE~x8ywi+AF0ZHRrbZSvzh$ZLheK^6;<+2?@cAo_ zQp!SSf|K}CnGg$~eXUp26X#~^mYAtB*Z+WQiV0UpncooWf(vl^AfT9KoO&O~duqz( zA846q5VBq=K&5nwK*%9?t_RIzg-4DjIgV|Y?U*U9N3o%Gd1U7OIhO(f`5&LXdMKmv z>G5ZhpuU`?0Gb)cRWFs$DVye(K$`C=M_t~vm<_9En7Ejw zU=J&cgphadl}}s^vkVUQ(v#EA>m^wQ2I`qaoD$t%b252U5WV}&(0{ac%r-^Esg& zgd4_J%a@IOVnUQR;12D)jrY{Mo-a9h0oQIT&t?-`(;V_)t3!Gy@+N@4(HD4sUHwsu$ zgsoz~&4Mb>6)U<5c@o)QLQlN$H0VVMnMjjFQ>PE;CB4~sB!=8>oX0187;zea+}aUG zTpn@5kO7$+=CaVElnk}L@>T-uVGOc|{>5)LL>2*pJwmGQ!d2H^cs{@>k1?a4gB=cR z|9k1-4l2r=j$s9ktf?&FhiMjTyF2C^L3j^YWBHY58Lr2Rc_Xoi(GEq21{Z-U6s!m zUWz$2h#y9?#B6$>xrKQ@Z(SJ}*fZ5Lm5gw*aU&>gX>c&w=EDtMzc6|&-v5p#pfta76g6v?AsEVF1?jm$yLQop%4Wue1y-2`N4p!EF?!VJx} z4Fa9S$nDl2;a`;GfAo)h7>!~XrV^td#f@10_2_G;>VhRdG_$ws5WHU|QrJUmxBeKL z{SRmOr2uu0#Rj&ddoXjYOfID8fIJgm1ebi13W162X7(LmNq*0P9I*M|L+a> z%WS4+Xt2C*r$_f4((I>X*p!1YDx7?W8oe}hlC6g_y&;nH_i_nn6fO5AWQlW@(aS?N zf)pNcx=6z=wIxVRq-v(7+~Vodo!=1gci&t*29Sn|lxnC_^H^V0O5fle=#_|PeKK)| z_~VKSzQ}9IMe+WG$nB8Fqb2z5`itaBA;9kYT{F0A2=!!mJ39Z*JSESy@MiRKnBij@ z(hv7CeA^%Jx@YIMpueSGwafnYf3J@jyZMc4z9l(@)mx-S-ke0}uAL6WHK&Jw%dJF=Rf$>VP(V{v|C=_asO0(aM(|RK3cp%L+3MQJ{w6 zxA57So1&lK(bl*SeIF7lqSySTb1x>KH9t9`F z_G#fjF)c;HUbbi=UMqRt&b$A)$e4 zvptujFc=!I1s^`?;7Gbpp?D>QJ-vPZJq;U;2p}hU$OGy|I53#D(AdGS$QzD`5$3L@ z8O@0f(G|aQo$w&zhn~5pXoic9IfdaUq3_daPu?c}Rg7E=n{TLofea^@r}r(TIb0;e zMf6-a8~x`^q8Qhy)@&3>v#>08AmOC&%)oowP%?uTvf!MQpw&QgnGKO?@cPzA(?N(78%BnFU#U@~XGT5R3*bc*Jwkb2(VZ zVmf6MvFFM;UcP6C@cEF=e8Eg5*pCJbAGl!7Wf$ZoS{^(gY=$*#Ev+MivirOoLm5*l zNzxU_h@t(-*yvmkSu?jXB2niPaSt4796yN4=SJ(WRP z<{ckW1J`umu0dh|wlPP<5gP^2RLvwu&5Q8~kC$6qzXfxIy`owX3%*|wI@2>~Lt_x6 zDtAU+`OYKfinVuGRfr1I`0z8RCIirj5IOjt&ZIZzzd_tudlyhBS_>R;hs^&7SEd0s zws*Xm7!98hfS8}Adam#Y>e#Wg$YbwSLW)>KEVwrDCqLj9%&NQxJ6O5{IeIX=Vj)Q9 zYlJ1YlXz>^!mfyOjqAf`x=H*9irY2Bl=;0C9wa=wv*$daoq{MGGT*SB9Ce!o1vn8B3r?11Nw)gp z2rTSY_wlVLGkCCG8bCgh`IF_j^UYEQAxna-su8@{@3**63j}AqbA7x8Fky34eh=rj z?VQiMuvnoZ2?8{pP^N^jBf+)v-FoicXT0Xp>ET?qBoZ(c<1L}$fs<@HYVi2fT=-A9 zh!Kh5q;~q~AZNb)NfeTPz@C}Lh7t}ul6+Gq^~p4*u1qy>65+KJ=y=3DuT8i{>q#IN zCN-OFBgQ^=*knric{C*Cu-O?q(HOYEU~W>bVkFxG30$PeidfR4E;21XyrWh_@?KBg zy;QT+pO3{BkF9?rmy7BH9fx3u>^d76^eDMGoav*_B@?iyR^Oqb@7U9IfwI`L{!qZ;WO7 z-ZR*V2~k-Q3H)S0_tCQt%QqARDSY5d*x^*zH zD{GX0FpGHA=W^Xf4rftur{u;FmEC_|h}a4;?5TO!IQSd5qq^&!iX4q3T!3MvN@p}a z{0R!jTDFN*%>HFZsTr7}UCdb>1pC=p@iy}u(--$7iQ|9(9T-jg(!>(!*>Xjk0ID%~ zio2q0uLMn0qQzE4|0Py`^9n;(F(HCa->*Af0-)}t%}}po8K3it$WcY`cw@yj9+1@ivSr^e=iGt6|TKm|HzSZ#jnDgvzFG=(Kw2lDNT!nZ|+l5S#RibRyC%LDV)*3uKxQk(`+V{) zD^f?)3q33fOX=;*3#?L~e4hMA{7w1K>Piu-)((7%apa4d9C_H}7=O(8?iNbUL_3*s zhU;XP)Y^!IsqhoF=J*hfrTVQwgxcwuIS=kDCn~7{1w*~ilecRF7cV*CEBqJg#IPf} zjtuZK_@m55jgHJB?x-Hdh=RHT(57O(~0;7aJd|=YG_f56)5uV1IfufbFVLBm5$6ARR2EXVbsPy#CbZ5Q{@% z-;)1u$nH<#bZDnUm7~}V92=7G)n~YA)8dy>3R9B^=b=}X2jR!R336WzDt&UkCjsgg zAVd!u;*JkrFskq2{mA>eT+=?7I4%Sh>>In{7^{xy%n1}&=wMgDiXRw~GIRcFiVDk< zzqwRn&E_6OPM`A@2M?Xu`D<`BF$>;b*Pj#;+}ZigQn_(c`}I|Y;)W@*-s7=C!}~d1sVk#zm!bjEtZnL6^wY-E!KDyD%GL8-&uY^jIEnPIv8vi7x8dq=z}I^VJdrg z)?G}I6bY#^q^87e=-;C}fjFhUh1c)@+$lDpl>H@1i-xV~+JuT4>IqR(KN7(AKAHWu zb#DONbyAM+r6CK?TSd=+S5@#fg=>$kpkZecxxUQ>d;CpT_~))B=hsD_$k=emHI?p-gmKqUN+iaiAw%K>egx++&~0!t3#@Ny z!KK>2uZE=cSL=iv1-HO{Tt7X;xK+bZR}en&b?DFNAIeE-shym#nmmh$y_ixgqqNf~ zbWZOkLS_J)kVE6`GBCfnn0pB_(&@=$*uU2JL}thgkw(dz_c!L;C!wX4>U1W~dN3w^ zCxGZ~8lDN^#O)nh@^qfYR~MQ;B(LjgpkV_t45bw2zYO3pOr?cyyDm_HV%o4~yWmfO z|I?j&^NF(&aY!xa^Ue@Wun@SuYaOX*_o%d{2`biRSmQH;oacTjkWhqPJbYTcN<^Ftt)yzr(o5Prgol!Een=^3tv_FkjTkxq-g(|c)j?Nz(JiFfCoO<3 zM|^>>vxKp1a*@5%R|bQA zwM}TDe<(Z}O6ozufXcS!NEam`wZ--PuvG7c4B(cSZ+L3i!V+j6*zlWP?4)9YUI2;a z#vGlJE)Y$MSJRYkl)m#~HeI!!26X9?f?qe}jFjJ2|3x>AB@#pyII9oBF50u(-4__0 zzYJpiBTzE$pqdQvFJI1u^XWKLB#1}7g)0hY7itQN82urR_=7h@euQn1qNQwzdZj!- z*o%m4NbC{y|EIHHjdRIrfbyr&9z^vZxEpqO?_y2C&84oqYF%Lif(-KPAd#7x{u2aX zHJcQ^CiYd>%~F@c<@fxU#E(kj{Xi*{+1;-g6)vzXP45h!5VommqD>zl84WGC?#55d zo3Y6R-b|uAQIfiz-C{c?YH%8O;N3Gfl7^=nciB-s*xk`a2Ki z7d2~!va%-be5HSPs?aV_`9);lmlKfpmc#b(Z~IDb-)@08r{deOw0A#N-HRy3I7s0%Q{4sr)QzvUXCE4IacPwOXPB5kS5Dvn+G=N4x3K;VCAQ`>1IK-S!60#^@_rn!`M@{lAOXA%ps^AK z1|a>WX$abx-g9kuHErrO_UxPV&JSW=n0onh_9Y|HR-lRH~f>NX_|Ji z>{YMUssFqMuIXYQUhJ#Qg4TXCzoyu|ebu3}N_)HM7q2cV1NXLn8#hDga+E8$zEN=d zU%{+qaeg$O3eTOOoW2bxFTB(f#TY%xukQUZpeVDYSGb>QFBJdh&-!}>8BP6>_dc3HnK+a497X zFt+*mXsHhjVC$<~Hh1ZS(xhfLi>RmEw}Cl?`yk$#>i+)YQ{MAL(p$ze7h{pL%J$ONf|hV$Ibx zC7_YXUv}h=s)a4pdD(_mAU(aey%H&!U*v#LU63MVieVJu7`VkUUqiCF!9YI)67_5` z{h1qjsCD~%>X-b+n0ZrnviU%8I{^XlD>c_o3_1-YUyljBvGkCNIBc%h8IZbnkuYEa zsVdNexsu?z<(ER$Z3x&9t>$b>K*hW<73A#o>Ub0b1 zys5Fnd&_1y1^4aYP0E*l+{@_%!0-J?X@`uGNaq%+x8XvtlKh?5j+wPTId$yQu6X+h zK)}TchHJig`e2{(jUAyjn|Kw|K3={(UUNT=$LBqm&W#0DJS9~ejU9k}4tesoIL6Zs zn9`vELdLW}D`a9kq1r;l(5YN%QbEpvhkk&~iGZ&-M)DF?A!~AY{d`L4|*=TjryA0h(Cj;p!px>qgHP^+oiuh+yK2SVjwjqqY;4`>AcWOl(7C_ zm9<{|KHwGu@zbf{y|V8mY%gHY1ly!RF{C>rnal>$K)JlWW;mhW?1k< zW%>->W~L$v0Q(7Fa9-UtlY7#d14E6i?1B1z>B?bewhl%AaVbp1_wSqlP0YzQL;yAE zpbgx>etAM1pOA}DfBcz-*pzw}*#QTKJ&5-A8hWVBT{d6%rh5GmSX^-`A4%q+9!`|^ zM?f@JA(PYl;h&edgmD$|FCp^-yBY`Ks7xn*Z*IUH9$A+HX=@DbkGxa%Ijo*(lJk5x zT(h@{YloqDb89Q@J{fn&=?Qupw(5Erg1#i;W<0~B+z>|C_ze?!1I~0O48;r>=R}J_ z$1*(!gHc|k7QD(Z6*TLn2A$jIiX`*BO$^unuw5p9yD}Op7VMlDD1kC#2PXs9{WnM< zhh9kQefUqJZ92C>>BP^{cA1D{=oRrE3D^999A}a6*jj+?CpdDjW;PlBci1}jS*Gtp01O)PUAEXI3c+vPX^jZ*ct-wF1{E=_}7dyL$R!ov|n!B zT?dxC{&H&B|Jl3+5dLG4zi4=AT!(x)Hx(o0-ZEQt;vT#Ttt1as0QoWpCrNM85FX&= z#O1JP{)#~&+1g9rzG-AiSMZdi_KgH)LrNF#OamU(THa0Uma%gBnL}taN@k$Qk_#Hv zd@R0z>tX?PliR{(^71wmI>?PuW@3|0OG2Pu&Y%rrFLX}m23do=#k#DGw>5{%3RnEY zvQ8x?%3ZLO^)*enxxKM|Uq?8zRe%%) zWEYP9^1qWkzoL4j6s?gsup`)+`)+F|*76uf4qm zLTQczA1C^Vj%3A(6%mRTfoyT7$#yDgQftD@2K{Td-K&HBeML6gY}q;g0JB$cqz^KW z_U0`pF~f4nH@EvR8I0U76RhQj(SO~!*>D#iRuK8$Uwiu3w(S4y7n{Re`9l)s-wtaH z@m&Ivdzu!4ohT1P(!r{1bKvCRSzeqY_?jjmuLXe@f=|l?75)k!7KYWd{s*@beCn3@ z?5^oxl0wf}(5+_Nqt)mB%pp6K)~BHR#b@^n>~j`jqwj6(Um8kcO%tCZD&QP~uCS4) zX%|@n217h6E+Pj}@KR2k!gQl01UU@c#}x4A_@857KD-sS{ z4cwwbD)CL)%avuzLfCRj4I7?sXga3M4%?*$XgQv;1ZA8-OF%-dC&M+55z~+6fYp?N zN>jPG=3Xg!9_+n@kyYX&5l(?9-U(8Wp626-OD1!76GC&rrc1ql!0xTS_A)i6WH}yL z51^!&86Ry8_CgSNU~jA&a65&T=vd68&?*&lKl|*i=P;8>8&%M4jh#{DV0H1AWS@ZW zQ3j#bQ&SrT;Gmw>{MHVRv&K-?mJ|bRTN2GlFpj-tAeHfy`K*9r19T?+i7mdw(IC4C z(ZSxVCpFMz1lKjpzFqMeY9lSDNz7Xm%N3(536NtPr1<+kADe$GAZhWWY`Y>qlR6!4 zfMqd|&EL7*5`_59sk0~67+t_cC4R91?FkO$^6mwCY0+WB*yDYvyyACJ^dfY5U}&}p{r4!yAoGT`dJprQk}*$ zO@tGtLXl>@NFf_F!SI7_$y|xwS;r%;u=A-dBIbDHdM{1tf~t0 zj`gs!p5%&5%Gwz^)F#dLnQswd%hdxGkPs?%wI;J7b;n}}4_(&=bPNwvb2X>=wX>=8 zVGKioeJ3_G61xv^XIb)J?OBw_h_E}#?cpa{6jm{U1pFQw+9EF9TagpCmDWZ(Jf}pO zCti1ZBxw}Rl=Pd3Bj%z{JQE_6V}1?<;#SG3!)x%f13k&a!>+O=N6~L;E%c*84p{?PD?V7zQx$0r~eCZ+WLKGtZw%H{R9CnyEHWz=fd&oLC5@ zyLkIUX-W$z8{(d?AaUzQfznDwQGK}k($4RBYTD(7T<-3$G$i3vQq z(L0Ar^jy6SoebyO9U3~yf$|xxG%uUN)wWB)%`bZGR=3RSVDgcg(7$=jt?_?PKNwrc zh9ht`RQvqZ$LnL399mq$lR$P`#koBD8yNw_n>U`a$H9aJW>N7z-SRQwW1_*-3>mTcI{)Y6Ebu6IwdF*zq!2&yZ+0wbPAN*YkIrH}f zm|zp||oml%pVNCF9}WXNd0n)~zxVyUBZO!@7#Kc}Fci6uK_aGk>7w0}Yv4 z2Tv-4pO0TpJ~Z$O?pi%TF^dtV5I&^Q1M38MV zO(4{h%l1w>-C&xHu$yF?7Lir;D_l)=2I7UAj_1;U~2{rWT_C{7#73~KjRL$#?o-5Dx`KqJ_RC}9;FmJs?M4IpCSJ~p%at*}geL+7MJ`y6g!fc>Gbnx0F5p|qoIE|8ZT>@QjnH)?$ErY_)2NAy89?)Z zQ0C76w;ves^c9Tu&nbk^R0}qoFu!O1JLvj~^$6ziax!zUn3btYQxhXy{$$mC^W>yw zs`}l1(5J;$EzEqoGQVkwk@4Q(J|seaSha-n6VZNm=^N%FJq)iSfPD7df!+2Ct}TWy z9)*Z5;l&R}#JX|O*?;En#?@E+euZg1bE^4G_HkWOSQ3y89_(0i5tPgLnE`&87Ye}j zfL#GJ+(hE!{W=aOTG^1o4|uBj&3@#-_ht;%-^tdB`_=tboC7(ibr zDb9R!&;UD6u%&s1dP?|FbTZ&6qixt3*VeOl@|7`Xo?G_4=bdM*^$4swJ!<)Ebp9_J}kaV8S}xX1c-Gl-5>CdF{o9ta^AWzAt?nMD6op95tc zh*JEnZG)N8;7NbCZ)>W+BBU@6vO3sT8~*G1+n*9uFFdkoRO6*|mNlo=ZXU#IW0bIn z(9QgQkNSAl^$!YFZ0Yot zo&DXuLmkrf66nJY_N77V+c3PSlvUqtxsrEbhN-#>304YZoPTYsxUBI!>A_CY{yJKy zUF$4|t|&y^D|{!hPrHSOY@7&ehYF^RBu^n8yjU`C;bL+JOnR1yuShC0tu&1~5Wiyk z+2kL}VQUqCFb9uwtoi15itMhi-rac*B;r?<`+kJC@;H3UnNCRQvbt0~v32Y$%M16@ z+5W0QXh_7Y>1OCg$YHDYa_#%ni}e1`ea@cub7dMr@=99&T!)L>>UGyQpLo|UuI%Y+ zJ;gbX;L`;Zl-@5ud0{wwui9?h6OE=d%F;1XE*_9sm9I6~4=k8jc0a*!Cj4ZRP8Y7a zTIX^IB#Ur3CXY}u<1_8j#aG;Ob24};X2haCksXm)qtP;ONjUT@#uR$1mt(dny`MUr zyeh`k$o(=xn9AWWUuX6O2}f(&6=A2k4@cJ8LbkK)63|U5Q_lp?CX&V`7?8P7v4za& z!wm@*(tfV|fN`^)_}cx|KX@JE^Xc$XdpX%aXvIt+n))Oo4#p{jvudpVfkK(HQQtr8 znXOEFhIuZJS)c8~%{XJb!oKY!rqUl_R94>@NbHx<-uD&BQDL*{>zUVeInypnkf~HX zd=gAFI6QniBSlWDs*dRnVE**$@_F4y^SETcY*=;1mOWSqLA=hK7&4l;h<*o?5Pbg7ZGm6)utj+1^DI85~0T}FH=2~&hyebG^&O@O&?UL5&&S0AEX7gS^&Y9nsoE$8s;gdJD$a`mL{L7%sEb@I-p{KR0iPu^)fbb1j4VicZf$4Llt=jH;5KhHzS=ldv(sd6i4nl4% zs?^XpJJ21A$4F%o@E=g^5-)eCX$t9*t5->Q2Mrn%VK?2rc$^eUK-7?;=NsCl{TQ%< zNjCMP`o2DCY9)tjJ+;?|{<%LdHBPse%pfi2y1ebmYXb`nXvuRtllK?Uh&ga|JN+`| zRUYTo*IMJ<-GU=yTWh#!il-kmSC~}~`X-mdwggALTGUJHI*F(;E(l&5WxlENO*nx1 z{z@0F({*E~b?$_GnCBF$B(Ifw|smKCq~JRhb-<1gl#F!@(cm@v^~GP!`&cQ z619Y6mlXsrR4;x%*TZ^>xjix&az2p%llo&~X9vH9xVG#t2cA|o5G)?d`*JcjGC;1j zUu`7z$HMFiEwh8Xw1$9DPUL#5*lyADiWtmcu11okPe4BUm7D$*z8_ofd@qkLoa;k2 z!0mp9@44~p=7Jk^nF~NaQ^aNU#;2Y<{jJca@0Y86KoVtVj_;J(ryN2T89(N(c zR^EYg*sbMG>`*43Sol_=-rZ5uO{IH511>sldUgq**^M)FrEwcDnCkL=vwd{ZY0c%^ z+Y(hs8^AK5ke3)Ozo`hdM|Arg2QOO4te}7_iSnlLeN3HTzmB-h z9h8}7lxUsDyq2Z;B1`ni0Gzkx(;og6B3kh(*!e6Rg|~&@ct^ocPu~z%FEcP!sXf^6 zG&w7@$v2q|kt0MJXhK1f=0?f*fg*+)amVH57#PK6{P`yY~{5pF3f^5`JHUj^isbpgj29AKaXwHClH8-w-#` z0yCn%`5x2HRPt*MuB}PL&cW{kbEg=meRULU2zqayjsCKctod6RPM_;$?AMKfl}FUw)#x_;Qz_a|mP$SfawPNP_6+9QowM7kX~msutUT9qdh z{^(D*_)(^meco{@i?qD@;z~S^f7_Z@vNIp5nv`2bRa?@@TMh{B0eFMjRtGRsRMFvz zKS^i54d$9@GQSs^U5tHbBbdC0+O!q0c+haG)@D?T+?Go2rhfBbxpAXwcW*I!?kvX{-oSm_Av$r1};hDZ5@FV=v zMzAuKtI&1jr9WLS?`N_j@)R+ZGM&)Dq_FWdGiO3t;>AQWRWPo~(7{^u@bo3(RF+=&Iy|69RgKQ5WMcPdyziMC@pZ~Mji5m4eyls-^L#=0XS?W&_MNC#Jr<(O$> zg0}HA4D!FKw)ASN*8U*UR+xWcEb)BFm6X!$ljrzA9>K!Zx9YR7H;Zx;DY3ZpQEcqv zwo!-7f*j4d!LUp99bjQ-@sd0LIa6SegoFrKhG(lVYqt$EkGToCB5-#**YJyZs7hjg zEmti+!^uxJtjP7tU)e@}+zeDPY58HhdiqpPzgV3}y&hZG0N2r++r% zJk}ieRSphn6ho+j6#`XBE0JHfVRt$SMMwk|+5ry|BeCW(ZsKBj7qF)zzHeGmLeu;9 zx{Ta*Mqiyu?Zt7+u&uf5zElTD$Yf%Wlv?Vo|6$C6rI~Mdkk@RoBc0` zU&x>;!d@D4jQW}o<>U}9N7z@yqy?W_%GDpx^x1Tm1)H6~o2ez41?Y2I%4n)+rFGX= zPuRUCH!tYPqCnH~S^}j)YQevAYZ*gMQKA%MhG(+pmFhGK51I<_-oQ zoFPBall%HSc8`{V)})nXf6EwV;pJ!xD+Vye{_4DwM7#v5%H>@M6xtVC_RIPfMk6X2 zJ7L`)k-;9QCh5jba89}%wX#|YoXVx(g*sO7u0r?a7$5Z9;;D~~mDVsuv3)#zSWHKM z{`A7LoqJN>K$}Ja8jvI7GGF@nyT)M1o6#i9fo8nhkm5vnZ>B7#oQCTXY+jNfHQpPIb zY2cs<&`@vGx?8B1_Jf7pZSI*@zSJkE%o|-oY}~w97<6kXyFg{FT;>-dIos0y1 z3=AQZA+XjTC#)AkbX`A45XJc-1`(V7`C*z^`b8U1)L8M6HzzKo(W65N1Jf1N3z12> z6~tF$LptG-e7XfK6^NiA!-*tFA`Rz53vF@nY-f5;=&Bv$-z$8JH?p-;Nx&NJNgz?G zdL`zWZe%XRT>3 z<27xdoWoTA2tz(<;|HbvC2ynlJuQ;1TKf(stpLjK{T+B_@Z$l`?RxJr11_4-Cy_~hkevl9;=6{l2RJe5GU3Zw4Tl|8 z)rC~6a$R_}P_L;-OgDa$xF(_B)vvqT0FO_-*!=E_sAFsxeh$fV-KMiLu6#U6uzJM2 zeBpD7q*nAX0_@l!w?a+}Ru)Ed>EVDV;jH2qHMv!ldEq4+$Ao14J`I+aWJD;SuGLOuMna?~E}!zq2iS0D#lE^nMen>h}c>JrJnM zm!uiWOf|4qjW`ZEXnF|r6@-dz;Tw0~x+W7>Qmb7{9}QpPYJvJNj6Pp)Y17xYppAo7lQ4& zqR{tr&$$F}Hd6g~K^OdTq&Pcl-;$sf$>|E*y{tkp;2-bFO+qPoNLV4^Uejih|3~J- z?FD}fUC`b+uE}O~keB5bccWh+FSp_xffCTwo^{fe1A^MVKIFx5h5u4CqO?LEd5hh( zf~y9OUh`Q!<21E;8Cj}74(U~PHM11Oo^Yixi1J%+SziK^ka7R8`VyW;;F*idf{&Ev zrFg?9a`I#?Km~9qLJ+V3RkTZh|J7PbT%Vw|FuJ(0jf?@T^>d$1r7@{&n>WOho1I7 zoN8Yr>^20tNz}xw{ZKM}rugOAHJ%)$`Hw6Z>Xz;1p%~6v-nJQ{R+jdSuSCx(aCR$| z5L08*j%9qB-afO0C35Gdkf%GXB!s`8uFjeVIms!IVzr2>ve8j?YEp?if){w(EG7{I z+Zm%BzKLzv2*}XMnXSeeUEe8-y_>zz8H_jc*&j#b_PqPrN6K9b&!o0e^Nz%UDAfpl zEX6dbJFvlrcGwQaY@Z*Hd`nS#u0K539guixcx~+uSAM>7`bPk9-LV!v?Yt1&W5?-% zAY6ysXFA8tTKvFA8Xf^h&)Wm5NRMH)^@dRYx&{?#+-ZXYJU?dHqWp7)eOJ|m`{o2u z#Xzw`wqRCwNwI})yGkKu3yh~8?ticK9x21M((w9+FJ`ZcIdHC(A@U0#p=fXt!;|G7 zOPw6@l0_uj2O#_q^xvcH(R8wpvN6PuXQ$TZ!*2%NU=~?&rAPKIQ@c@}0$s zj7uofJQ+C6kYnl|Lb;{tlH)oi*~fcqm|UNWcrs?ps~SUyo>G&*1&ROEZQV=xea;f$ z*>;TDpMWc3uJ?LUzcTTZ0-hDUN@#Dd=SDC5)ZD)ka^3gYXIgqV@NGwP-@@L-4VV5w zg@-{gCq1U7tv3^Ab<3g1(53i>R~o?xC{gBLS4!Mjp=^ZUda|DZ=XF#_lsSkQqIGC- zi-O%H!rk@$CEG=+Zih?3V|s|l<6zg)h<7qAig7Bv(wX}WNdy^t0WcaqmKM)IA4~q) z$)$}?>xlmGoV?4k#AvCuKBNK@e+59hM|i+)xu0Fy{FX=sOF02MF>1b5Dv? z!PWRzFQs`!nv?ZS8tgzd+WvPf zs0epNeSzB~?_x=9jLflGgdRvZZUAk>`SeS1h6!two1uNM zCA|#-7OyWoqrxnF^FQ_X2sQ$i5@_3^j`f=@V<|=s`CMT^uv(Lima%i%(cJhDlrO=k zPj{{$rJ~2P8DHzL=0i+1o`*z7aN^zli!d|8XPSOWvo)tdn$Hcz`?w>teFk+FH{3o3 zsMU!=RLU!8NOAz|!Z-3;99;Y-L_`eXOBI1vHo->&{8S!F-<8EC0j^P{ z!*9iCj@re=?Hcvur@}?s>fbJ;_nax5(@QLsed9jnCI4D_&MQGeeDZmQDHFSP2c(>dLQ$*m%a)AWT$wq)~<{3REY!xINp6|E1VN(jVhO zYF%Q=1)o30Hd?N9V|R&Ib3KuPjf`-+@sIC-=1#v}chN00{-pm9M9e4V%RgQbx?S?t zh$buVq@}OT{RBC4mdu&H#d1~ii3xqzZb~+|^#@}!d%2Y}sG*1UY1irk|0vOY+L@y` zc?#@41?*A6+aioul{1C1xz0p$nuX z-YuOZ`38R7c5>6UpP-p!aMoA{z2*%2dc!<+jL$wjk6^YVO6FmC*&_O=5X6z71)bLZ zk2kUR_?5B(-AZ!j?;h?0hPx~h*;4gu57N}@mfY6=qfdypr1Z_ zzALMO3lOi24QE#BJ(JMh1IX$hMT?`3UP(UA!r_be;~%ftVn9h*!1l?H!u(5FkW^MUb}8!E1$Hnzo4j~tn&*AG`aE6-hJL|g zjp86)f^j_LXO1w(e~-xhazc4w&N~wM=KVrWQ3B~Yl5^JN?iy0OL)4V#)r#7#)+Xp< zrvY4&3L9_VDQ;=qa~>>D)JaTn>6X_Myl)pw{8?vt;?Jyl2jn5yR+xY8yhufU*Y<+o z|6=W|!=l{!wmrkZz|ew(fG{A9Al={)N=SDKqDZ%N%>a@TDk$AZhjcTfh@>JV(kKc@ zNSEIY?$EvW^Sti&d;jA&2AJU(=3eVu=XL$=pm!6F96?qvN%AGYH`==|w_U@P@euy&}w=HgTX+zBST6_^^=i|@KJ!{c@SaCORUVd_GH_R^KB#MA6OE62r zu&b&K@Mk6DdmUE?A0zfiplVo7?vw-Jhw!S`E+{{ih5E-O0v717F9Mw-=UMYhE)ajg zX8NQUu$$muX-RT^ZKsdWhJexIk~v-($AT1j;F0==3aIS;M>7&M4`+D3GLkf68Uc^@ zGftgeY%|zGuXBVBx#Ymhzg{$y63-pNozr$EY(UUN7sLHtd2sJqUh1@n5f`Zr*rUG9 zRN1>tDDX`Bg9+(eq*3?LYij;_>G~_p(a*^YY&VY5)3Diez00@P=sSybh(l_x3_U66 z`gX4fo@#CESWebq8EAXd3r4@chlR5=YqvwgDt7bfkSP|=(%J=DNat>s4l!NzNC8;~ zMD0WAF}FCaV)Q6}l@4ORUNr43_0gvcGB>6msNt2KZy@i0$<_`pj?lEwPYbAS3jr8h zNYFq^iN2&2H*>KFE_&qb2t9rO0wV8LiZGf5)ZUMxM1<}z2C$N0cY}L8gPIXkA{~$h zSPXRM#$+p>+M#l-k;;Aa;}h!k`ShdhiCZJ1jtgwJ!v@iZ@#8WLR2miH728SlALp}e zUCveR!(Gd41S>}#6|OY2CURVjJw!65asxqBH(hK_oQb|Uj4x32UfBs$txjW49Q?`G zQefW^R{QC(3<5)Ktg&hCEnwY60hhx%^(mvgYyV%>G!TGBwr(i6*=U zoy3eeBTB&q=BPzC@$+f(hGaL1lw;Q<( z9Qik1Z^my`w24`P@uqPVVSUL=c7@Z$Et~UjKWWh*V7x9)&l-*I!~GWZe)!|P(^K5N zkIY{4baqEg@s{Y~d(5y7)ky>gaiWKz5p2TC2_HD|Sds+>9=@)L&~2?UX;Je5heg*u zwG@&rlp`&F;5zt>k;zsiK(LgN$Wa2fxKt&$bpaJQz~CsvB4Iy zHyxzuEg7p6*MK`XJjfx5Z)2gsPMsUAwC(zu`q^{b1B=JnnX(C~&fIbNqai!$P+ys6 zEY6uh3@O#vu2Od8(Rd@MiY}zI${&)GFHl@u<#`|sT{sR z!^1C`LA>v{DVDFyMFZ?oU*i5+2_Zmv#5!f3UIAf=R7Z!s<6fnJTvFm*?5 zdg5x~4?q!ll&R{OeD0`mIYS0q`16nk-OIrid;oI*{ccSj@lt6Wf3kL$ia(Kh;J+gE zS3#kWny_aQtuiC-n=Y)=8c8-e{Gqc7z^_WX{{uzmQ>N~_(%Rr?+2Pt&=1Nsv^l0;g zSXT#!uc#1Tt_)vrDy#1X?iy4k)Qz%*IPqNQ2;}bM=fWg+@EIzKZrv#u&N%)yeFsHt#~+%cYA8ZPwz%g_asb5{t2?&L74Xz_G}XyI?zu?Kqu zJ;xSLd0^4O(dW_M#nX{%&2{t@Ho6BB29dVA8hOYX14}mWofG1gF;ORi!XeV85BKCXJKQCpf${(zE ze)9_abH{m6$nDx>^6l7waYnZ240$YK=YIbXe@PlFWcyVXjf+--58M06M5wNbZY;rt zKRCqAC_6j1-GNF*V&oG!Twal}DFar#bep4_U_?dRl^NkS*@(0^STXhi$uv zCF{Lg@{>=}p;i^*HrimvF-Mt^xG?Pk&{7^QQc)YULV`8ZN;zD_vC@Tw(7bJxxOXjn zZ7J&-jB((>a(WB&15-WIR6y*A)2^jtcfym#wj+`r4(qFxn;Cb^n=odKg(r%=EcfEP zfSCi@py6w{ATgmvwVODvIlk&)Zoan!6RWF)Dx2Zv!y~#G31!_i9Ki;*g;z8{dbt}_ zi@@7DZQi|UP*r=S!dT30Z)@WScw&!|v6$b-ubT!vk6l|cNKJ1UV* zCIL9{xmY3k(hW|;_vk(R$PAt$wN>g4j!|B~fYZv}%WY|*I+msOKl~^W9oT*aCI<%o zQ%%mL;K@l~Ws;yP6gn}zUMG4GM~9{|Kx%_%n7~F^j7W;G?U{+vXyT6uvtaMAC%KHs z;#kaeu;o|nO=c$W%3T%IN)4%lARhf1Zl+G;S$lX$G*P7C5Ni=nMPp5=WAeR7DDR12w7f?p2E&I=Al;i zEg#(cC#A7eZHT)1{TAtBkjjGMYeZ0|mXoCAbg1(|;7)DrLnqVb5&}|_z(dj7VYQ0I zAp5{!3W>K~3=5NwV(sjM1v$((kWGCJ8Qbd%M1Axz2@8=$paWyglMD`IE?yM|D~t05 zYILHVe~);XZY#c6x<{Mdbhmkv^eoDSFp%z2v5g2dJAu=#BmH0;jR_Q;}M`B zA+L^6$By^(w@21pt+5>Sn#j;C27!0Dx_#=)$;aeG1h#cRSJgO>(`<`(tHQ(FbU+tr zP7IU5$iR00`y<_$d5Ragy&14odNG$qt}`Q^BzJHT+udeH>~ho2otUn?JwQ0$1RnDS zHWw?OE?R3ze;5jS0cj&#Os>izfvr)pga5WAB(|vs(=z7J$BwPm zVyUp>6E|2?JOA5DC1j@9A0r?wo*1&9(2YSkqO;A^_?7_-ahzfTLN<=)jh}z;#kwoGg`)rG8Y)P&0kuAQ3zZQ{&Z406jej4vv!A&~5f^Zvg%TqK8@Z zMmBV@$5K<5EA$`?+6f?kS{^WYT5<=bur|R~Rw_YM*>vk{wlD{XknkJXP#~W*7v(mg zr79H{vAYWo8=NTS8jYn(ht6xH@|>hS23K?k=%E6IfmB|Fgvtl0&Ik3gJ2-hM6p0r^ zi4?7mBig>Em&sg<)WLnJTWib?wM(QA*)-LEi)MMU$soDAcDPYje>{ zeA^6JW79cDus&%;I4BuHPA&fQv+i&NxLJrCBHn6bas^uJd*qYV6b?=WeR=+z(p2UF zbl2y0Ogx_1#Tpk-{6z1^qq<1%d6%ZJo@z^c$L*<^rz7NKkmM;i)Wc!jO1QSdOeT)HA06e+X;t_J$Q}nL0AdJpA1}4iNFaR_@97n62JEU!gKAOcG?*gN%`YB$VRhFf z$~A&huk`XvFj_kGy5y%Cmx9L6aUh<>Qw(z;`XkaTRy48X1#2aqW86tx0)KgVJr%IC zt`n<(ot@~VZ2R!KBc;s}tnr;a$>2>?=iz^_=Xv-K?1>w5%AU9}|AjpZKy{3B=cEet zz;o9>I`o7r4NQAlxB|OWOR7kR+ALhY7E#@|{mqlFoIWE(Y;*9(I;4)!LEWI6IL`)Q zTloeK4~e=k>}tngUpx;eooGE?_gyoi_24slQ>7t4wLqH#%rooq>aJt`&z<4Ol^WOQnwlO^#RQqkKU*dcF%vp_($B1M)A zV<;)KT>f&(WWTyK$|4r?c|>yGF5KcxQnBfFtiCPyP|&7$1?>D_(%uBiIr#FUQmH*; zlOk78K^;nY2cnj(J%GD?e`{7TKyRk$1uOCiUem>`MFPgqTerd4CbZvzpHn^(>x`tJ zmKy8@BV3q5l`m^mzBR*HlQIjo2}#9ld&-Su4{d1o(3!-}k4wed5~CkYxmPR#QMp&j zR{ad2;M#|DyvnMeOVTmmBO~)T-J{0AP0t1{MZ;!@G^Qsir(B5UO%s5#$6{{GhGITU zV0j2MA#*N1g>B2Tp+(O3M-a2eWL91jju%IObv`|Mv@7f-@bYvd zNHc$?$Ze(2E_TvPS#xPE+6#Na%(ADK$v(z2cFbaKta+WlNPN`^QjD;ivvXpyT<+vC zZ+oYZux?LL8er4nm$8RILg#d4$X<)NaXA*Y#%TFfaT8?TCib3&aNIR4Q6GmbYtDIV ze}+roAk;10-*6zPMm}L(2Sal&Xr6yMXjf|>qKm^r{c-yemaL|-hh`jXEx7>E8&uQ8 zJGZiB$G z{t17J{1tWlI_(_{D7SP<(yCS`aq+X{E@0KGoG$_i3m-WUOXOu?`|YV= z##~(ddOnXblK`p4Bby~_@dcwIx?1);!i>nhR1bgXZZ~bb@Tk!7>Xj{#{lq?B*cI{{G zqgT}Ak^Pn4l#V-H9b6!~IdilRHr3I8z ze^6c{KzVBb<+=Y%d5yW&B^+mz7ytN|DX;NQ%4-BDZwjEi#=laY?q4X+`IPcfx?z;g z{a1lMls^gs-8$5|w7v+~*}PtfC=*SMH;Kram!ETd3y zHY_h6&a9Cg1^S*}5n429Gel2|?IlW_i}IcIhTju7;%2nJp%z%FpITJ_61AmdGl^UBsfkYu!i_0d$5|07OT#DLo|6 zyi84iPKzKw?;^HyQ7kK5YF}@&zWIWgaa)IFoN(dR^>XSU6Z~wx(35TG%(yIcW&@rn zcD(v_iDhE%-PJWuU|~?9JA2=hm_;d~y>MljVk{nq!?v;oHsG}9B(6anVIqncb=MW( zS4ftD6Dd0SepCnkE(xcq0^E=dwg4IYP`IG1h%w%mj4|%OGG$9H-E+g~*p*n60U&Lc>axkHepoGeWM~r)n z$sQTcQntnS9B_5U1czzy9cn0~myiO9O2BCnz>B=-LesTJmloYj&#yV3*L$@~z!;Oj z?h3CLO)vat`5E1Dih2G?OEW?TCo~$ICTs#hC}-EN{`U<~N!@YTD4dA=IiJDibBkNp zt4>ar=FvXJ)XdG$UDqxWr#J)DJ81qDuYzsa?m8y6ca!*bIvqMQUP`3gP*`KBs4VabA_d-^#Cn?gUUqn#h5mf!iYX)J^R^wyJOgsQyQ;x+ zPR;bQJB|w}?7De$=cn`^l2c(|6FxnJ9L_G4e=eR~9>$$4^Wt#yuY$fbirK$s&RG`i zZ2h?y?VGMM)2!LkX-)Qcr3ft|oV+0n0RbR%0bnDII8oDGS;EP9*z4j*rI^biVaD)@ zE~S_+x5PV+TgM8wQ45X0W4m37mg8^4hVz1YvtZHGbus(R?lD6ouf<-t3w@A|;7nEV zBFr{gy-?s!#O56bdkx>+pU`j|7}O1Wb1UFpfyTGT3Gi#~*CWRW4)a|okGvd{jB(9h zEc?AhV_MbuI|3sTOCuBDeN~&l&+4k_s2$$&xEed9^|ibTqbFq4*A%v%8vicQ-Y-M4BCyEyOK_E8Gxs3IeP{F6^RZlJZRSG4F(> zIoFeUTZmr*YW20tfTQXsb{j;?q@YwGsKxl?hymph`vCdLPDAJDI@6X*zFsu&vj_&ye_;jFm6OT z$y`!-4Q4S$^axYZWSyfBmwheId!(7Dhhv!;81etP#FJh8BCrk12ex4^w*??=r0I8_ z@wRgQAR>jhTQB}44p+e2dMs`hz#%CT2D2}qvXqmcrn%JuL}4Q;Fn}EkGbtxc=7#|F zHRMkAEQXU1#bb<3kbVbBP|lW#V2tt0JoC(^JU&Uny$rG-(RcHAXtjFtsO_VX6P_6f z8DG=0M_O3LiEzcVhAneqeD1%?F^d_w@HU2z<bM$x%9 z@xQng>j7va)(4*WIA|95QR6t=i>G$C%ghn*kjtGj)zN7VmAX8&q-Y62@<5x=p_x<% zd=eL_qIow$kY>_;^3@tA2cHrF^>MPu*5WDZqt_FeW{z*DJ7&HRND^SX>)2Wn2PXY# z8q>pDkD(Y+WP#rQvt*n*pnAqH{7DG-2NgfNT0WJn5#|TcRW~OPtKD7vTP{{}D(01-LL=a=x)NEqWj6_M%Z6Hq933q>n}9Dv zKHCCG6gh0WcWPLAS8t714}^)tKYd`}A!@u(^2vaW(ZM-d1F}SNTVlZ|cB@gJuvjjq zQlLRvMM|Uo0=9l@NBX`ZVY{c;WX*+97sA8*g%60 zr4;HPnfiU8rb@)@Z1AmUH-NxC$d6;J;CjY%!u@0Si@@p`jPXQrjPbqVVVHbiT?p)% z3n^d~2DN0f$SRi(6~Q@}%f;y_Z!ED^*+oK(;ZZMi2Aj>rnrAhy_6t;1l>yFQ!M%;C8WNXnl+JCs&?GlL@ZP!V#ZKY z7pLbCkD6ljh0VJ-?sd-K1KD9e1x)I`B^UV)OeegHJV@eI1x$5z!Wc;GR~%myzj<3_ z63EK5rQ@PFq-r|o)uI-T_@@>gal#&x(u;R}3FK0FpWrAba2pfk>!*$%^u>I2(x|S( zneZXzyH!xW2DK?UQu_jV6dKR{{>$feB7s}hMuP%xljRe*KjlwI-wY1A+93JXpCC25 z?_H&O%+Q+#v>v{chIQYa(`DZNh0KOXv-a$^qpVJyv`zZ@NjQHKUM=DB3EFS)h0T1& z9rHH+!c>@Mj)43QW`Zik2!^|Qf4vv8((O2yW+v|~cEA$SkTL_jaF?`cBUfRT%}DtVO0d{M+7S3@ z4iuYDn^U5Lw0FVU(@5RHOy-awu#cv)CBS&AXN*_f#GCPJj5qiXj5qij<58~;hg~RU zK4UzJkkd79eL%iaEsR{ea}UPBW&zUcr)8INiyhERH;WepNq2HblBCq(^-4~VsDr{J zCY#K7l#=MZP3MeU62fnJk(D15Qa(#+Dy4o8?3(qn&Emfd^2L;2W#GR;8ceQd-SPpBM69ut|^rv4AWcb{$D~Wpz4`x&D$AP0hk$IsRA zAG46R^Ai-TqDgnC)hYzE?zjh-W@s};2fn@5AFCSJBt>|KIy7RrliOIHF_?{7mad-( z73v<8G-hw#dmHV%l1M{yVioF@14O9U^N$5o8HrJlKfaxK56kno|$C}|71+u zBVqN-Tt5&z17ei%`E9Of9G@+G}WWcx0v|DM!18FuhwFc!Xok z1=HUln7M*8b^9>=Ky97gn>;qU`1rB8^vlxa&Ox2ijHvmn+~>~0=6v8uB02`4Tm-6> zK>SEqEE*dnF+^#g-ra)CBh_5Oe=zH8FsvDF{y1CwE&~gq+w+^d3ox+2zg)BPH_D*CfJ*&D}X2xCqY(nm(Q~=T$}#lB1>o|FYU;T)&D)kXK{4}r;6FFAUzWHe^($Z*gp>WpxJ zrtfFHF9-=X$=3zZ>SCE1XM-wT+VK|e!<%@cufeAy$&u$4gT@%2#o(5QD$OD8v0zc8 zcUFsD!b*3gGBG^eiVB`qe~5v)Rl1hg@#Wl5gP7=71T?yknG^)I0LV|ZXcl*H*XBlt z^!kpY#CdAa4d6gxFMHom0QBWluXv;V_8sipv+Cy`Yw0hEbzL^bMQKC-U&8As>LT?}3R}8nCQ-Yye5peuCVuv2<{}%+l7k8C3!3nWYgAA` zG$d%k4_7;q`lRJO*-BbUP~O2jh)PIPQ7Yz^D-$?{aKg_ipq1YQVkQ|0V zI%E2-?lI+Kq+bqW`ZPMjDlQE(Er|5WaAid*_BZ8L+;_nffwQYS(l{5(u0Mvkne5j; zLP9;rR*apqCkY?S8AoB*>^Gw@`89J0Wp%OdZPO@Uj0b8!M{A@PeXdWZNf#_~YKp-SYK~_pjx)}K~ z%%}@mcK2NxFHuqSq8-4WDJxhHg5Wx1Q`p+CFkY>^R1@zPVw|LnYSdiB-41LM<9IDC zGlQOW_BXvsW31C8;FDqirq*%5S}7fYC*bj>l@wv^sH-S9k(Ch*j|yx_(1`jb{3NJRY#Bi+&OTPHrvoP9(Bdw3*ccCqLTU zXqAvLVNwZ!4d*K!tKP*Rd!Ha+CbW{jo`A<-P}bnq0?x+dQ^f2-;D^AV~s5@5|WPsFVeVChBnmR zjQ%~!DpDz3^wAU=lYM3-KvX#Sx$Jaq7UW%^770dOA^uB){`E02M>1$ z=@`8;e~`^;VENVVBy(~bL^kjp1DBY~<{m$eM?Wh-*$-E=p4$Po*~yp|SZIKL7G&{q z^Xg28#nL6LY7vuto!Lln1i4ds-#J4Ch+x=ZEv#0Y1aghp*b*zFe@8e0n~d*s@ViUt zZZ|FOE{U_|Z(qS32y7!H69D$zCbMq&lzFb#1r~Z0>W3!rfqgd(53uisv%R+^ItnYk zE1CVFV|Dmxaxi6{X48gbLZz0&T;wY=H&E>;b-f#1_U>kEsE54ISe3yvp?(^Z9q)%g z2m3+i;(&5&8$So8R-Wj!`|Bc(9fq#jOZu#~`WyO=aGKAWbtavNDi~2_7}@5ZdFXnbd!tA%$;i_Wt7t z)p{Dv#7-bPbcY%TQyYI}d*plqZoK&Q3zj#oUHt;>C$IPfS$H0fi7xi@d)(a)5vPb) zwuyf|lihN9p%D_s1p%MNH*%HS5#(@5CQ(jp&!N8(-_}17-Aw=+&EJU6^>^Y+zD!7{oV!8bu61iccdhWB zfRE!>fp6^BfNzYs`O3)Q(KFAc@Ab%$(|Y8{X+3hitjfSX1np%L1kA9n2#{DwOSkpu zW3<7e;n->-<_fxWRm&A-jD0_lV*-lZmO;Vpo(Eoe3g~1$uDxVm6cxG}KOvtK!9OFPl$}{d z!T$>R(tk$2&woR{JOKIfPLWUG6!{8VNEc@Y?*YVDw6vyuMtr8H#JBuA@s<5Ve60un zj`;NcB)&40ZQqSM9C}00@x25Hvf&dh@nHMA6%jSJMy{+@T%EybK5=OHT4b@Qgl(}l zhaPr%uL*DxC>~2&dcnFFx#{XESttYqJ~&nsaR{n!#(o3uVAchOZR?ato_q5W@^HYK z1YvJm84xFJ6i9ZNC29b;TJq1HA|G%#-Fn^q{C~)lCh}%XC?omHOy&qvQ4?T;dmp&PrUJiz{Ro&%1 zP4LvrXYA2T7p!{;GZY$S2&j4ZUrU_AG7518mindnDkrmY-i$=;fuMrdRE*vMy}5Wc zs0LOGV+qoN=RHB}>IrIba!|=ASX^Mc75q1?SM-lsZ$crV?C)By2+(@VziU0w|E%?@ zn<#(d2KcyFb2-GICCJX%e6pjdl-VTtpJ5ce&KZcKdBAg^iL zT$Kk)R-gG#fcg5-qItAE{61-%=m)2kN8K#c~9w zuli@|L;n-?8C=3D`%ZlpqW=N<2G5|+_pi{0RQv<_R?nbs=?CcJ|F_ULoCQt)6Z)0_ z=v(>)=;H^VkN>+E9<3-WarDLWfzutqk0Au!u# ziWfsm2$w#KB#Lr7ISP}85}stXq|v|KjMI>zWYrb=rkh8W7iOeD66LZ+E1p*ZT^PM}a zv>&NS?1|*2@}2J=NX@2@S!K{W&N$XxEu)L#Dx9KfRw+gh6xLvD>)OQhVkM~Rj$jn@tuCagk6TH5=^!8F>F+`XTlRq-%oU7zIya{ z3@g*F7jv1mOJR5;&)oa$@b``^E`hWY)`N-@cjv)469c z7W%#{O*Evn6uV|T2UyO(=n^QEJ^9`}oQQ{w7;%^Y&-H7aL!$xAWw#ANPk4RU{d?HY z1bACN%X>jci;xv~%Z^Y|-?VVT%S5LoL%}FDJn>v5wZergTeetUkM2`<%opWg z$G@*MdQKwF*UPX$#Yh)wm~$m)IG^^$Kv1QRs!7w8>@{`fyi$)BlpN`chEs$GQBdYQ zB1#9TgZ@3z(^(hc)TMTqJaKVb)?F@wqnK4;9Lnl#7j^;}Q3Di5Jp}J@b8W9w~FaaBV|-`f-vv#B}z_953XSrhV=)Nc67$h=73|iyW7Ed0~AIvjTE3J9ft%Y9T=|LE>A2v#T!K1YqLH65U zlVoXWXX^BfOxk0iEXP1-U>5(y+2!)cIh6d_5JqpVY)uaP6>mBwOeD2lmsHH91#g;s zU1VCJX9xAnej81easc@YAc{J^jk|vFIfhTTr&JThQRALxm!vk*BJdq42Rb*5b|%$! zAuIxIl>45G5R}vRfZmo%c``qed?qde!==bV3p`aQ1(p;QK5azS)<}U*)KcuWvA->) z+ff&Q*053A1)yoW$cOo+37b5$Sl;<7EJ9%S4Q4r{_(Tjyyaads1_SN6%5bt7`+FzP zd_qr-QXcnZi10VhMzPx-efGV!y7KDh@i?LQ@cX~k8?*-Ln-zs5M?6XudX=%A7h8Q- zx0?;f4bQ_l;wkyBh79LB*=*V8-U5wx?A|}?4L<(=?+pqdk9Yx}+=Z1^aYYT%tz&xi z$|XqmZe2<>TGHw2Dm6YlYmMVHy{Rd3TOOct6P!~W+dBJHMvI=fDU2hck5ty-Qzw)&Vo(jxH_r{o#bT>6(c>g@E6^yJKAuIZiaoTAfmCt1?jv*>ajalLDvkn zi_}cy=}H)Dv8KEqU|7}-zLPSULIh9?k|PclHylob1DcZ_&(C4@(SZ1M1KxE=8yj*4 zuhTN#t#w;cfcwB8c>%Rz4=g%;V+=mkAzDbC-z$tJ^34DzNoK%FlGZ3b&TinNdrYmz zy+Zu_uyFUoyCGFjlF5g_Ng3HDJVNS(^{YN4Q(D21E=t}p!IK1Lk5K|8G5W~dpCJPD z2IYrFzhfT!6!Sj)j(IUZVV=~##60;wF)s$dyxBXz)BH2$Y5akC@H5Pl|7FaJ`5E(K zPce`2JLXXYoGpEOi^R6~$#P40*=(dG?=j^ehfH5) zF613E8*$}I&nSG0oOMz=@ml3xwh`WpeJ*R+6SQnC-?!O2;t-r1{kR;s+4zURc$jXS zuaKV0Vu&+cjBH2HvYT#*e;!PCKA<`OgH}(_4b`{go4kxi3ZIonU8lANhZ$}IstZ<*3w=aDGC@Hq(XzZ zLCjO+x&hTUX*8L$Q(|GC+&0*O^LL8yEAWkTyCkdE&86#A5@Nph{nVJV-}KGK7(dNR zxF5sI&Lx*d^hF~p+7kPqW?M-MZP(em6gk@T9#Jw}EJwJjA#fTN=B_mO0?PE;B3XYpX^~ zGm1;&ga>XLTq&q2GfxQV>+glV-AK337x}yrg(F*l&C3=1@0gbsZJ9yE=@GF(s-rOl zDod8o3Rq!H%SCiL+JStp2cbp=BulW$<0Tf-50{(j|j*v**hvF5q$p3trh zZFZrPcCB+5bqVdm7?1ZuZ^J7V*T#U~9^3Tb8Rt?RLs+Zn&gVZ;Rt$PP2fo=!srQ%s8TI~L);`cS|SW!<;>2E@dvB3JR7_~*}hnr zmwUd4S;6yx>=anKf9>>ryh`!?N}5KLuV|1Y|9m+-w(Fnrnm4#IdhiZ3@#8MnZaNJ zH=pG<_W(#CNkd_z9@} z{omL;D!}F~{nh5B12%6EuzA|WW@ag5@Y4d@H1W?>4h3;Pb#0 zzxh0H!%sfX^k4cs)1Q3a*ms{t{ystQAADZfpFU6U)aRN0vd;rI{N(e{XFl)p%nC-@ zkgUII7Np@vn>YBsvw2HD+q|X!Z1cc?%~SpNHgE7}n>YA(n>Ps9y!2C>hXici&EIXF z@KNqrOOKF{<&uH0Tpw1}6!^c*n&JNCSu>r;;{S2b%mRN0GUapR?#t{>FH(m|@(KvL zX=%WvjF5Da(NIGOCQwqHsAYyxtF#76PH*8){;0=h{?2 zW8YxFOqs1+9Z!kSSKVz8=d|#LjZzCPTrpQh=kqBb^~N0J4#2X+_{FW06((8G5nKMvSUQh&QC zq&`j(3I094i2&l8$Uozoki;M3o8YtfCdBKPA@aG0`RA@;tZB#Hi*fkBjtVK<9#s}o3Cf%VdD4RuEkA^KlK(- zxyT(-91Bho4yB7HzJP|co)<+)YA8iksM1~Php5)q$@fAWf^F)7dT7#Q z)-b5#m&U_@O2S$w&hndd{}=hq-5>Isj^Fc}jz8o#(bxP-=LX@=Se&~YPv5m%tg{5m zjAMN$J7%WPL-U7vqID@*Kwy9s=1d`yv?kU6OvteHGh4+N#*W>H!8l}rvhIB|?gG9( zqe8!DVeC%Z4-RIQ+OVHqDH}lqh-*}X8l-9O@O0CeOWGi`SYey( z=U}{isti&p=(mV~l}TAaq9IExmIL4KCB5X+lAh7;CB6QiN_xWovZQD6XGyO=|Kw(} zEf&iJocgK@$Wh1`jN#>xB!S%%HEPbU*9trE4t{~yXnIbT98z3mjr9=}yKi$9KRV>~ zynswGnObv6zr{Ax-^=p8F)X8XBx3U_Iu-8D{d@|ypT$hn6TYdhjL{t#spF^-YEu?4 z-?1TbdMHMIT2Vh0vJ7KOQS2eWRMeWma}b=NR9WJjIgkiKBzllSUX%IGFU*LEg^j+BFG3QxX5} zm_-5bJ5D*z5#YS!U*o*~f8f0S@0>TC1aRJ^1N-kfk1&_)4DMO@4GH^jv-v zdflf&FXnGT@6I1WkKX$%#aaG?(Chvmg;GBjIRZNG*>|0H{g-u~=&G+?}M&E3=$4YZra`QgL21 zuS*cuQ~6Pn)G*~qOa6fJ<}X$T#})bgDzmTZ5{BaV${NwJ-rA@PVIQm>$j)ZApk3gc~*vAx-1=FNB(=&@XEXfT%yM z+7ty3r+*BkLNZx>46H#{nJmMmN=oi1Lv|^T8P0(O1rJ5Oov12jgiV1mz#|w5;5du7 ziw3)blt|BEkga1k-nG%a5(j>eH+GcfeaR*h~}n&rI1x5kf7fFE)cmiUeJ_~MqD^oBLz>?p}3Ev0mp z;xO!u&?Por4 z{lqrzRuF643|}s_nMu8cPrQn_QpkexGge5?4XidvHs$VniV*sE$QZ^S+xHMb!3x_t z*1p=sIB3EK+Y)lLGyu+!SY|u$1Xhbaw)$(9wN+>An0hoc5d()vLwA5fq~;RFLGVp1 z@250uHIOWir&(x&W(PQ`Un?In^cHL^A*YGWY=Jw}s=D=&^yi*l!Y}mn#{b^aGyQ8% zZ{w_|XL{PxyZ(RD)9VF#dfb2O>G`DVm;yb$9H6K7xd;s`cEZU+r%gS8`S{3yuAV_0 zd)clm?w5Gj1klwR{JpC;_;Xip;$L?4CVuMbxt(?O(yzI9{Zm)Z?vJis`dL?R;+MO6 zgFkomD$lxlc*(O3gF~ZM_e@=56s6%cPC*7UBIW`5Omw$|y6y09a|}MdJPD_@;FLhG zJX;Kpn@PAu9ySW!M@+K&(r2q|>v=I}FM1z*_NDiY=56)q6zkFhb53u%4Z8DlxBC1% zDb(&#Xns+)E?;Uz9do?0kyRK;e;FMk%%^!qSBFe%N`1G;mo8R!M7bO{W7znsmWSx+ zF3b+tg)IY38WzM$CE#^^rlt~wcAD&eAy~6F4G@uy3U(~^RolV#tmri(2-PwV*!S$f z)bw#CA??)?7!3Ck6mIi7d-q6wTk({bCWK}GN7Up0b<|7$CDcp*j(Tqa)T5#Jzd*g> zA5gFIJL(nxiF#51>b*YK_Dv3B@Rami3mXYL%r^{gr{J3+6x~mkH-;lxCrij8 zgwQUzB8Wp81KV|@0u%Ks(63W^_#ZG`e48kHcQ(F8?yy5$fI*>MX59ZgBs9`wR9VYS z_BMCnF0`exI*+n<3(6Dk9jMygnnFfR)nUp!;%i?F{uY*QcQwz9y9I?%>omRTp!_f& z`SNxws#>o(a6d=8^GtwLTjr3lfk)HB@BE5@t=-)}N2p~P%KS^)pN75TP zBfaYXMtY{dkskVY(i7UfRdINE9E z=1AFQXID!meY@C=mllJ_uyD~0r>N%o7%;+?W32rI*3&hrR9=mKEP$7IT&_V^~0|v$^^1xagWob_zf`<<$RSZqjYSVTIplX2BqM_>8 z;{PVprFKesgx?5$+nqd>K684UKRCUz|E<#-``PJT{0~m=eaCg>|DDr2`N`?6{LSeB zsSx?HQ>W*4>hxAVOm&1`=U^T3clch_O9qVI;_pUJ?PsI6|L=mEtbZCkHI;2wr*g8` z|1b%Z>5oaEYP6@5Kx2P-5-97>lRyXnhm#kQ8)yb6x7X>Kevv9!!ONfyWuv`lfXki8?>->D(V} zKc~NlTa0QuS^k_*7OBl_ z8`cw4EY7n_u$yNF@OZPxdq`&qP28FffDOSEi5zU*wr{#4jtjK*1kd+jS5Yk zzI9IEH^;KJFOaIvTTfR}gJ6!NFw@;Q81Pc4uAs&`<*NgmjT>)Rq}S+8p@TNzq}<4J zpIzUm9Cyi1E1!1t0GUp4|Fo@l+Sj{YsU9Zyy|3r~Bk7Hul3v;0NYC~U(!2GY^s0X# zy|Mq1^xmJ5UiDu{k5jp`><7|I0{VKtLV9JVq{sOO=~2tSh`70i$EmsD9g@$0>C6|B zkH!4;+zZ6rSJTha^zfOyFDM%hx?Cd(Y+cD|xfB(^(tZ>nNJP?Obg}BP{6U*>Zsh%i zeJ8^$ReIoF4(Z_;N3R`abQI@g$z(j`JJqJ1nX$@IdZXXG^tj&WOy&1w(<1Rkc+24- zGu!nAmxT>N%)7y2-O=eE9>thXgH8fwB&vxDh_L9&Eu{9xCa&r;#~#(+#5ZRG=8_nb z`(@w6Urb^T-fWP^H=lXEF6(_IXu9&N2k{tbWXAdXM|h#4Joo$2lw7&jBy!{h6?$$n z@Xo+Q+`)jDdx9jaOwVYA}z->B43ZUy{v%y6i zq=j0LF?9iCTmg8J2^Ss6lc%pc7DwxGn0vvI?iiIkv~+R3H_4Is<1mf$p8v?2vsXc@q3~v%h~;65gJtp9#lz>qyjau zj5uN~9ab@!(R+B;(uNZte;=<&1#&&T83o8PyQcIxl}2FdC2LS@Hei`CM*WIeJR0gB z2@p))-inX~jg9q=?!WA1NIDSxf1G^E_IOpuWpS_>6_r6TNOBe>cg!KpZ zR&BbJ_)y{mirE7A{uJ1x0Em-P0(M}VDj=0fL-=Yp^wTV+ba%0GC3R27TMI#qRx&wj zf}1Vez{*IRz(uJi9+%Tal(+xEr8~D=K$a~3^}2Qacoy_KIq&+vC+8uM$$97hEjiB< zNxKF7ot%gGUy}2#-Gs=B0j)N|VdHK__kYT8OsV@b4@3sxyE5cD?(xDJYu;m~F)~+RKbcO#oDqK;UMETBQzH?RM#>Aj zn$yAL@Ij{4$H#c_{d9gn$}@k4$D=3hy>`CaWnfeBW#st~44GChgRI)UGA*Rf8JII) zCu&m`cy)HE$DQ+B#il!hIK>f2I-4wL*0S+mlkn>PCG7-%k#RgZ{Rb&d>T96B#4d{t`7I5R}v_@6EsD zhODB}(IEM`T)^Gyll{lvdaCpNY7h|4zj9 zAc?q`Uql?_Q(^jTzIE_}ZI55!kaHC$WVPtOWXD30!GWJk_~rjnF%$9sP%+ctzgNsu zl;@oWDNLHqp%+X!PoF$B5z99`<+WT|Y`s{!+pVJTZ<_0*ocOX0a4HjgwId<7xAN%T zLCksi%bFJIN(qG@7X9e+Gw>BXuqFQZ^_+Z=ISmpryDmi3ZWaz#K_?3rtB|GhNAp|) zY$)Q2OTJ>wQ*43i{o%!RQM_8E`hKgOfx5EZC=oTdp5m@orieplH9k zb+|TfC%CgVgK?!*9~2O}$r+rfo7v%*l`K=)q@XXU{yEC&+y4gX4$L;2k=LNi)2N zf3{Fc{nap}qDulVs&#{yJ+I=oFxg1_<#i!jbP{M{JP`-=GE}^B5tS_fXP9E=(*19K zS6*K>vJb;v?R-3Tmbbsjn;QVJ_>TU)28!=%@wMDmkS_Cs@V7iYiqGq&Si<}HG1K3Q z#~f%}-#nwW;pV?Jy9G)$v=GX2{2yA@7nbv6hC^G|n7OrA+i9j90q8$v7VnAonVsaj_|{DlBtimp|{-oTHuD9DXa zEY)p063}~T%5Elk;C9jjI!GQqz(CvH5dAi1*yTUfJz?m{Tak?mI{ml4<%5Y~!p@rN z2Pks%%ExrHUfX9C?L#6hLry+b^%(+uPWBUZaN5(WdeM~f6G{lK7Di} z<@3a#TL@WcVwSTdtnOQv(Kp=1+PaLfmcZ(-8Qk8EJ=Aec(nAT&7tGF)lzP3-*EpUXoGaDB)iG!hJe=$K?u^ zK2|aVKcO`@chik~bwz7uu%)uItD}oFzrAmB>Wm>QSR_*vED!XP?>(ira3p`~Zgond znoRQa$8(7OaUQ{I!|os@YqBf8;^jZbK>cjXC^0tWk%H{vR^{j~P28)EmoVo3G zG`+H}#5HP_9`^T550UBEp=$*6Ts%C^so`8^Y#-T{ZIk@22Y+^rd1mjwbaMCo=C18E zrXx6640mi~$vC+?>#*f~%gBf2j?X=-mb~}6f_dgDtIq9Y#{mZ`C2`0pItX{Cud%Zg zcJk@Csx!G+$;n*UY-1#so#lQx0TNqX@jm4O!|=XEjjE!0_lj>gy&(eX!zSQ%9!{x>O;yAirIKl z&<;(HL)d*QcIv>5;#{2dc78k96YH|{dW>G$w`-ox`z>s2QIepQSo1Y6=8j7eOVTg2 zL2ut8NWMLd@q4}xZoD_@&-cAwf^t(u-gr9=)h+SUiPK!T9__P)X&;%vyi2h@v^!A1 ztHSxuS2O;t``y~F?iQX3O>9lEQdizbjjpMrKZnh#a-y6$xt4pGU1e64YK)9NAP^cQ z|JV>bq8gCR5YWDNkr>8!`)%!9G@6ih5swS&HE2v>rrBg9dPSVjvi$bAl6Hx??FSN# zchUw&CC1e%h~b8oX&AoO0W`4dEJiiTp`>Je6(NvZ?@RrM+UM%9*0ZZ!S!4_Cb6z>{uW_7Jw>9MB zSEqZ6kITnMi7ph<6#|LU2ru-7^Pcno+^CI*L1~o5v18#2(fXW4>~cO+d+V`qho$Q1 zH0G@5&&E?*w1#aIKe~EJAA-t0!<=OQu_Uyiqcwo4u7-G`g*D*rq3aZZXeqtN`NQ{JZp zv@0!U?|`n@hRLXk=G2TP4g#6Uzy((=HB9!r7$K+>PIRVL9dhId8t**^Ab(Ga=K@)b zT~?~&SZKHsqn@-iwE=q(RMti&BuTtB9akm-sYOYcn}8F-lD1yWAs1oP%C!5Jc0CkS z>n|_p?o#$&oEky5QhUVRiF)SA9IRD>oA7>Kj6aBR^<77Aq_v2~uq!{LuW~C7uDQ?C ziedLTLHhFf?f9kc{=Bu4T0pVpg5+8D-5p-ro1YFoMggDFqp$9$gX)g_?vx|Nn_u>X z9tKUmDkG4`D{1L##&~^SbAGcll!I2=D7KFLGfqC$QU(}|-Ipo%Dnuz!14nT*OzF#t zEYm8aZI*KaT%yLW^>c15 zr-_pTEI+h(etMtPxvk%tNEMDgR0-_gSall@e5|E;jOK$+tyU(zFS=&t$6?`!w`)BX z?rr$}<@5;rXWHA_mIv^*ZgLM{A4s#;#usIZWn>wbX(P~klv~g-Jpq}mU#>Fv4(*C* zmfUwk%Z~)un751QMtF*Rv(c6;zJp1MY{5A*E2s$fJb8r(%`zIlXh>IQUR9q7KfxwU zOy!1FaJhe|#r*8A#y@BXNIhl06H>+7s+9NXx@zw|dZXWU*|B*gZ`I`(G_a@bpDu*W z8s6RIxkGpQu`#g#cg}`4vI0J*kvB}1)W=v27<;F84qIl&V)&X6yEBc`>%oW=r2ro_ z6uJTw9Vi zsZm#@)J=?FojiMnN2qb)6zQvmXp{~d0mA-d%Mxm%JuTFSrMM-Zv9q+EEqgX%*!mH_ zupbI<(w9!0l7*b+4`URG=ghCMjqPSSWzKzuq%Ln6PrtlX;J|P=w7&8%QlT#?2g*w8KVGVJS(2*fQNVv8t)Vg^)wtKo(O9< zjZduGlm13JwzB&Zs>*zuq76J~+g-B4^u$lTc#G=VnB1>5{Khk;6z%|xj=@}|Y>2_$ zgisIwzJFl65iQ%t{xvG*e~-$e8isTVBT|*q49Bbws8BhQ$4+!7n81z=Mdblvk1=G? zK3leCDdXICCKy6{TWy;@7&w)r`8RZYvmTilSi%p2NcF_wQmt?6Tl(M|>%~~c%r^Nv z-a^(=&0F8Mgox;eNHZKH0a=ROWi5b`F=>8+(N_)M@J4SpO+|vegVkT48a$D83B1f% z0yyv-*Sc37xJXh<>pZ3M4E2fw$aqC7EtGI;A5oWc#wKCU>5ar8myD3nvRlvqCEKa8 z0nS$GBSUgYDy`HH&kyoUE+Re_Pip8K89tww)L}7D<^P(F8(;RNJM|WNWB;-7>_O+t zM!^xnrqfktS6okhCGb*Ps`a2CFAUc7)!T$Y;SstEh8d_P!UUvTIY#eB+h{eK^$T1{ zGLitKG3alT)lkyneXSWmO6!ab2uGr}vXiXT+__WO z35A6l&Z~;KpdO=J*rhE-cN%+oVELiaH;SOC(|ecB!?gvkp6xnG2Jr3Ql}xYSe9ye9 z`F@-30q$Xl05SDKTLNv|KjvPbvZ}l_UZln)VKbL$2EmW(C;8_zSQ-ioGaK;)#I>Eh z78xgT0Alt$vJE~*JGKc@)=0Qs(?9U)T0};dVrZhmUdYW z))h3^eBtt={4@CHIs?+sH#QKA^*CQ8`)<<2MgLz=l;$rex@Y!*zC>23e5(kvShMnM z`u_%-UjKqks|niOnXtp>6;;i#rZ?J_d7ZRj$GEqp6)Q2~Y)STRVR~y%GRhUQ9G?DQ zxH8RGm$XlCr5qnD3tvuWB!p+oA3a<#`2H(wX`GG8D zWoccEgL=aWFO2-;(l+^d5(9)1#vqBk72`4h8uK@nEug8Ej zoTBDSRA1g=UQHP}*X!sFP}XVudp&m!o9airn>b<5czIj)4Y6_Yzs?NR(`+`scW*poSbLRkWkOPzIwn_c^zF@eTxmS<%&%G&`rEbI*j-63AypOYB7jD#`SsKh#4s2+I2%JTP4;_#%9O zQtqp%YI!WHbEh#?{ltT%Ab)o$14T2I$T3-#zGZmFd^5N{=$$WWaZa2qS8d9W63V_X zTLEfoK#}DXEi$xJs=(|*oQC?uPABLv8AssaV|P6J zDjNed%R!>UqR~fL*CCUZ4v*r}v~HR_xQUyM#b?BEK3}pq%EuuRh*+;0qQ=C21uB`6u~iMoSf*Sf>?x#8vJ?lM zerW)y-+DxMh>nZNaVJL17{t2%oyl6_I~;j-d?E5h;3GjDAfHDyOh`V$!n#X zQ48X%capd(3$0*W^-7dkp+Der6nJ93tGj9+;o&Bm!cNyOClKtfO5O1Sl1%X>3o*y{ z5J=1wbiM3~4=BMfKNxv*^9&e(R2GYgznv_M18ceXv^?PO;c^S8US2Z zzkZct9QIgU^BASE9ccY(aWl@4eJkUzQgmdUKO;BPJG3pPG0bc2X~R*Y1>a&7%{~}ISVcG9#0`V1 z4tgXL&2jiSU2P;LR(y?W7OLtUrmhxIz05Rg|%>K`$#+k^!G`YNoIF<8L`sW~x%OPl%)+o5pAgo|n6c zjb^494S8Wv_{EG3opJH?_C|QE!F*bi?X^HYLmdSdIGy8OJ>VJtS%KYUS)@tM7hYHwBgONvf~2daj>{CN{y zXJX??9Ys?Hli4$vwyDD^H`G-qI@x4E!|^-bdKMEDZ<(+YsO8Kk;N-wF?3gf9Mt8!; zxKH{ERPg=%=%i}XySiUJv?`OD?w~T^o&dYI+JL8J+ELEPq=dweYhKt{c3Q{x9Ub!x$cGM6qJvfeWEz0sA#l}72igNMhcq9lPrS}{+^PPAhoV&9oawv44;%;k8Nv_8nWV> z{w%C0woKha0&V8up7lX5lz8L8B`9V0VS1z*;3|&JOMV`rNgPaW%9I?1K);qFFOL9z zgy_1YK4B(b&kJL~Ldu6(E&X4bVwZ&+P9%?ZoBI{u13Y>J9?6{0MepLrJmT?`O=?gC zR!y>7;XUl~k{TghjP{JqPt7`vIX7^m&wpW?Es~a!3=W)_YjRkHm=#|=?v}ci*b{Ii zONvPDwSeM|p1ndlxvwB}f7E)@&I*M84WvBNx{XZ4NMHh73-fM1{znq##&&!R7F3&u`S)i+T=NTln#knLA9;2 zZ{xlPe`nRC1?UKWVJ-nyHU&}!?()$CahB*@q;}LtK0?|r!-s$%jjw*J0M6%HIK{akLP@xB?*Xilcd|}2?NU3#MaG#pRCFN8lpRbA z-EY6e>-2Y!Amp66JoZuGXn&wmTrA+|oes+8I?Db9TLM06Fb1ajt}*FjxZg_p7mm@@D4E($OEbz`|GK>Zih^HZ zSo+&YHI$}w-^9(|7w>FM@FoBCZ{#7!&%h6Vd7^*4y~h4aHh#Z7CyhZxM#}y=?tqgH z6Y$qJkk>bc5dMOZzrWVm8u)tf4h*0x-u0$G=F)%z-tYz z8gpIr=N{~d>W5%Y938TferMr)1H>PP=f`_sSeL|8%Iv|Y|xRqo%4x+8fQ2WdbY@cQY z{<$nhMNy94C1{dm0W~ES&9-SXcN19qE!`JRbb=W)?)=GBVjbTr${)T}&?iJd263&N zniGXe2GQ^=50`**@i zO+P3cZLu(-X^eO7Y+7D^C$7#jKnv28oiE5h*^=UqiZq$d-1=cF;MvU^e%6L+)29aA ztK&>bFkwB%BGr*`=0O?e@{%V<|7V`A$JE{&vOS_l(};OPtJCwP8r3iRQUe$CvN|Q} ze(+;$?n314Z!gBJ+CUQ7X4b9Dy!oo(LDLSWJNK-7mrVJ$UXUoaBkj zORvBNwA<>3H*-G)xyQ9UZN_$&oHY{18^_^?rlZ?pYmcBR zK2jB1ko`;;zRU#rbFz?05;g3jz7Njcx=J&p{tK)rf&6;-YHP3^y`8Zo6UjnHt-YWf zT5H+@1G(xm+X~_tMMqdY>8R8uGQpvwni<8n8?CV5D_@Q+AP4iG={~-~9rAoZK7bEND;YWra84c9MjAJv_K%JABLrgLryulh0{W_pBfuKCdR3pA9=f zDhmlK@|8el{(mRs@e)(w3bI49kRP?>Lr8!IQTNs9#93iHLB73a(FmRAhnpA*T#=bc zk5+g3^N->xDyZ}RT85hVuTl=3(aH<0&NIEn*x{&hJ;eWrxmlv~%TIu$#Qew+ZRAWh z6dbyuotNfEZprvtXJlZ!zw*nj!4^4eb_|{awTGWmY{W0nzN9WW1vTdteu0>PpLPb4 zCd)b>KKXYn`3b*TUXXrpzC5%5ngqlTLH1iMX|qp*haq|IjZp4Q)JoN&Q9p8*FLodh z8F-P))^=7orr?3zoLN}%c+Q_L!}@V3#B91r>W?Opz8?Fm4H)g*V0(Fg*Nsmv zsMzR|03-K8Z-W6N`h;XF7?Kx%Q8qy;eQvU*Ylsa@-?r8WC?C|MCIhnP#;_zYUu3lJ zF#mD~f3Kd-$1!?I0Zin1oJEn-C3CjMrKqiynOb>Lp3O(arT8z-kcoR}!OF&@p3mpO zFZ8d6HvH(Fnr3ATal9)Vpt$I(>JZ|(!~#2%#qYp>6zeumq=kXMp!yU}|F5%_orG>O zVc>m^9`=ydlexpbH0ZIR@|?P)+AiJ$&g*an`a}7bbTHnzHEiB9Sy@*h$F%x11$mT2 zZbhOWcr^M~@bIr2-e#ys@dNa8@g_%^G<-{AEq@H1KLCVCcsyex<+(zYsP2+k-3+XB zfmqX$H;l+bRu2*P#vc%DZf|89gRcm0LK{fW-=AopOdQas#*qKJo=W5798iVQ@TUOR zP@-D>+6|R4bApDBje<29t2>D7%X=0>_;|J6L4%Ye4YRj^$~pX1ohxYz{529>o$ey; zw`lK{1n$FLbh=s5B$dPwqT8_(n`EgrUhX1eE^Ji5qU=RTGwP*@NReM(T;v!+bV1LzZ+ zQ8b6jo9v;B{;Aa~hBlC8%ro%E{d0wL&c75Xf%J7*zP+=D5|xn>$>StmuLwrOlQ|2H zNzQeZQ6Y@IpT0RuF@FABHvFM8{rg;7kA`*EP<`};V=5jwmEBS>L|@!rM_bslj@A}O zI28UQ8P*@W@5Y{AwEhIIJci>5tS0XL|kyx52ro39~e;Vmew1(k2O#2 zeb0jLSf!#%%3J-;bbN*2d|d02c-VaOj0(kI;E4bVCs+6f!yBPL!x|BgpO^fU-sy$X zn-+hCl0{F9;~Ev!TF5galX`wIk85z=1};HMf35uVLZ&UQ#z3HIHDI`4Ntms;XjD3) zX_;-pjc#pf_-350$AM(~WjaQdowZ6c*{je>A>4k`)fWlC+I`_vlj%VK&%J;3=8~GT z8_%FXcE7b95J@$D$c5XJEbw7u?rs$X1g2?A3;yt<&wFa~K^%i2x8;=~ej3)xoJ-Ry z)16VuB8XwxF03cCCHq*er;PfAqepN_M^Q>$Jd|7bw0HuoH#)lH5G}Z1Rb+%*XrM_M zI7+37XbF6lje;LM<=^&4KZKlfx>KFx0Z8%jh2jNua6hF`CgEQ4jcJPGzklEtbu5(c z_QJD>I#e&JU13K)!gqb^qiEnsUvUbsH7JugR0$#r> z+T0rm9B|ZnVh9HBX}?tx1FZtp-}nUDxzUC~NK>+rs0XNd7?jKK??|{A9rBZ$PxGta z4G395ajcq?rMY!TSUY86{7YLi-5!s6ixAeN%9IGUb54@SxVJbKIG|tB#P@kk9Mj#3F^WR0i&-Dn7P?91U@la;f+c z^dL@=o6-0fH}3_&&7)hJw$d34Tq+R8`3>SJHnp(%gjAUA1+w`vm6d1m?rwh(0&ALAN!9n-qBlf3?n8mQN;S&ibxdL%@7y*KIX$DI>@ zVLXxt+0B8PZ{5$=WaAkc4BobJk}SeSmH1pVRsat}hHnLuIIPI#VHYvEaR(#xXn*c0 zWO9xt^KJZq5Bn#aeiz;(UF5;R8uwAZZ@(I&t;|CW*BQwA{F<+Pgnlo7_+UIL#_HN} z!+)W5V6Ko8Z7gvr4cC^j!=~!y)ifdCI7@s=)S#Q#O;kmC-z-lN$=>e%WWA!a;gU2D zBMSQ=_$0`k8o3`Mt&}tn%azh6_|d97axLpT15bOP$er-~QIrIWd5vp+YDBWBQ3h>P z(3<*0Kf0#QdMVBj5xDa8wa11G^8?n2>H+7y*=8o z2uBIoccjI{cx6qyDOKL+m(YTJ$kxs|ML|K6_HB9slND?6K~p5u_obWq87_-|o_CQC zQxeY+9W6?9;mE#wUDlJsu|z7oe?e%fW~`j@RV@^uC-2mj<~V1;yZ&HIAr13ApYan? zsdRG&$w^X3hbeh(3lZJkcc%hFOxVh4t~b+g#yf+?j)MoN<@6+HFK6r@1!k^D_GJ{m z3%t(w*->&a>&o6knfs?uno`AVhb0Gr)XlN0ppY31# zBUsn^xn}!?7C?m3v%aFq*XU{eRd!&*g5EKch?yl(nM}+RtnZR81~%U%wg`>6M++&1 zdj~@yjy|tNc3RUo$hfb;QmUSC=d@=YW-_`Yp+Le4y3U%FZ zCEa9u{qtu0!Qe}i8mv)q->i4waW#3W{wwyZS67k$n(AB&1G~9ZbooGj{*Jp*Y<9Ko z;AS=BA_3`r8teKLEZvHQEqCmESDhdC*WB`JS$D9Rv2-25BgGwU^*xtrH?=~d3IyvFNx&(GgQ%|U*q+kSN22@zfp-uA3h;EoytbMmPJiO6VhbA7umId* zI{w5;N@(6o+ghfCk|8^N7uv0S)!ssWxQi8F(F!6*2VZX0($$Um8`PjlREk&j(uZr~Ihjex#@0uv7)cAr)=OLoC*I#c1#LOso(HL(O;(x>AUjsC;9*n;v zMiK}Tn7&pV;b^JfCxq$qH}BnzP6IVV4@l-VpdZL@+Dk%SS1lzmBbvW9j;Ddrr0gpL zqjjXo3A&^}!Xf4ECyr15$l!TwN%mV*=yNLkQQ>Ww`z#%9+nD2S-XFYy?InJ0{7~6k z%O5gXFCJb=%~ROy>x6Cf1-@0eHb2%jVLpCx;(0jvAmGaKnh%Abk`RKi4`9VLAhBLnf#8w`PJ)&Twra{1GDR$krG)k55%qHQx)tEoRt zPFLyuQQyJAFW`guUVv#ULeH-WfEU<*9RCB4nAaKnSm4gs=G(o42f2uc3F>IJ1c~2B ziecX64Ya_s>1Vh>{n0@EV^!#0)3L5Ht<9y~sv0fS@vho&JDkqG;*MpO?3JM#(T)G= zbwStV=yr3u)$L6+81X#pdXrTCXvmb4hweD2>Pai;;^=|kG#+1ZdVSdl_NHG9jf;TP z+~Kh2)q@_P{(#~Z+G}Q=%mO2Lt5&Ke?EWx2h7iVg9 z6(4bhB@+kTYS}~m5CJ78^_#dG9_P{D&mg;D!(3P7`4bO!jIJ1MVv?PM)zL0ieyUv& zVbIu1mUII^Q*Y8Tg#$KyPlp7j*t>8%;E`_>~DH9rg-bcX*)<(5V+$n`1OD*br52wrUQ(_xGdxsul z;1zz+1mi34c764DFGM&;a=R5eH1$*Liw031(_;_+tCbsMnQkUW_y?h|Ow`*)FqQ2Z zTcOdK5nItcNF)745inC~I_iS+JTv$Q==)%e3fjEoINC=O0iFb5la!=9-4DJHES4<7 z=*(R#x@_PdRkW?_{!U%p*$&JtX1izuewO?mDSJY)JhW*_%vnEE~Qr* z51N~>;rj)>va@?8U$sMbvPs*jc_-@PGdnzF%V@PUaZ`gyunFs9)|W|~#geK9WULsI zp3Su3!!N5ybs-?0gPqO!!haS4+qkMcPX8ABM~qS5#~=Cgv8QBf09&j#+{ z02qntPDX31O=sLB&#z*&k8{Q1V=Ssrk&4Yo#`e{R->z(sDAO;mhT&CZh7W^UU=3u) z9Sx*{*Zp4V&h;05BS-#20)cn@$69ad(pKTSsy-tqz;IC%e(K6-%miH@nX1sK?(^?- z8A~l@y@<&54O@&l6>r`FAE=jtZ-|G8CuRT0DjxKVOWhB=DRv^^qsH3_`ShEd`L%8* z4c9HwY@wbeXsP%*C`Pm+oiiv2MWyPwG07O7$G`rPy!RDH7{Mt8}dupQM&f|N$yUt{3i%6_8d{p(^*HB@>06WbNyGkBfCwNo4uW@lPlvAe0OfCrLK>8GZia(FcISR_}0eDZ_S+F`Vx$$I-(+GDnZ6!q1-}T8w5f z+XRN)z2?|pweU7^aut#Km=O^TlT1+7S~}&Jt?hk;{jF2M9p}Z;%gu^qPsC;lq(smL1hO1Wy!1jGq=mzLHyxY8mC0J&xR|N2O)PNHm7>yRV^cQd$4`YaSxM;F}G11 zr=jB?E@*WmbI?*$S2p!Yj2y-yQU;-~-@_>add+_^4Zp0|L0OHf2048o*n*%Fh4aD=ZJIk9Jc5-z)e!+Th|k zel7Q9mYV)?@gCu8!~3vIc7e&JE@0JWji=`;UP<*}IJ9L%GXQtI)|O@B23c5unQdXm zg6bCi=7IxI@b<}OyL*FJRa##Xm~ld*_V}_bY;Z;U3v*lV-W#K+vrzC;a7|$&BB(d0 zVeG1-Vb#*Q>kRf<+pM`6C&GBgPYU0OlgBZo5n?DS`yJBUjyZ}9XlDb^v2k_2vQ1{t z{ZT0N{uIm&Uqe?%-TlFFMCh&$IV*|#Ev*>X+8UmESP)FgylS{4U}#Q9cM~4%dv3hU z9`;#qjDkW3W#@6PBtI7|#l*n&MiZ!pGje<9IPh%;D0&kUp5jDH?OvCGJZHhtU2o9U zUdyZ`2TF{HH0D?x1Oxr1kCduxEwln|=l?3J5iCHfuKM$HpyjuA?{CZt;R5<|5&n36 zdBn|xY_PGPgw*q>U{cLj@@bf3Mjwa8P`!gM-z&qf3L1{VZomP|suI3n41ac8TWaD3 z`KUjN2jep*S)TV!U!+;!hm;i8BeAL};2ia;f&`rToZ5H>iE38o$Un=;= zMzj!a)&}krviY}6s^8%$o&olNN{`p<9n138{+!mmC!a6ni|w44Hj_L3SH0D+H{-v) z4t{H~)s`8DdVa(P_50qhrx3WNz#A6h4s9N!Cj%hMU^H$l6ueg}qpJ5!B z;QQHaZyh7a5vyrEy}TM;&hQyeUZQMwlLmKHW@P*=hz8C$hEVeDx21c-g%?vOHR((# z^9PbL)GTJCFeb`lXe@mUWLsB6OEUM&NQR4*GUM`;;!(XY#8n#V6&YL$|E#@YxJ(P? zcsx{by(csB+Ag>CX2V!$!0|WcjGst6lUMb4$>LOImGn(e!((u%WZAw;Q7b8YhlzaU4iZh& z`3EgpKd;Hp+Z>rteFp3oy@ov6$CFgUdTUPe6uD!OSr@1UiPox>+Q+qi?~THvjgD1< z4-ISzr2# z2dl&|JIG7CzB}f=F3Z)L0ZrcRJUfmq3{t0WYIRH*4AbKsRm&6BK`$g7Gb#5UmC?Kx zBA>Q+-B%hxQlB{%Os%=P{tzl?Nw4Aa~n?{Xot$>B&v;FHZ7697ESN%e}9fcRMz#E#a+7y8H0Mw2Q($SsQ=LQ;!yzEPqXJ z!#s>iZ?LwWE`jY1Rdy@7-&>KGBmY$qK_-P8b++5&uRr|*D(?KS5uBJyGN86Q8s)9B z(dgrlVLt078cZz>Tvg-XJKrk>e#%N^95F{t8AGBM)_d4*S}@(;B?OIqS~y&{J|f}85RY9cv( z`(XZy%!Wti{lK`?t@|A+#ZlXMyufk)sUZ5;9gQo1ftjmVZ+c{YR$vMAwcqYp5crQvDDZJMy z38>iX%DnCHcUdGakmz|I<<_19#Ol?|@1Dub`nL}qz*)dDtk9BTkx#2M=%g=$$<0Mu z8G^N7o}h+?+&aF3(9o&?kAi(6*5wK{MNB~f)wB9L=s(8?kA6V9KrqX3*_b=Cy8hey z6plT18%-$ojmu@YzGxgE`uNQ>6{X7srfh+WNLgNdfG@$a2k~)ideWS)P=`1FO1UL;aEHjf`D~i-M zI{|Gj(thv9OFmpzNfrITCFc?^+HETlm1f{Z-%Y6-_OcsRfK5rT2tfB#t{OUkC-!i;n5B<9I%!sESj1X;U98)Fh~)My zz9@GwI8d*UC@(JFpVYx*=Xi82AV4}UX)rEv`%r_1Z0G7ZKhV6zm4nUPO^R3sw8U%H8DjGeIO`JQJ)7g z3vVb8fiuiJ7loRSDxQMG)s#M=pgL4XYmiF$KdXz}ky;w!>1dfkb|6d`A`P-)wyHh# z20Z-e!lM>y{+6O`=}$S8`URRzU5gKopQz~E`E87zgUh|0UMTx=7ax{ZJDJp3DkNG6ZEY+7THq3!0Io2qv8L0$J2AEhCEwLT9yBghFuJLtd~Zb#658 zLLFYc+tTdj*RxGL*?oD>Hk}~(#UAcS^x*m$Ul5x82)fX{`~c%FQDwkp6It?l8X${& zFr#qOV@8`l@{Z+X{)_l+tJMT9%5{l5JIVzpZJJ%BCV1Pb?f&@l1;y$gMMLzi&Q2kI zsH1o6H-u{-mbxOdvQCNepIeo=k)kHEez~&USM6bJy=QVwKuUaeA8~Hn8T96>S$R3C z1wWHRL|lrqFP_~e=6HyK+XJG}z-6%SAeAp-Zj=!x z#xITGm%#mP+J>@Ok?r~2y%<2gS6r)r7`-OL2iE4Y;zt7$MDYV81R|Zns)XS7Fi!Jg=QAa&B2+Ge~@oOK*ZeviR>=C1YRI7a3~Kf(I~sRK=*RrZliU(l*m z%SXWegkrFUSI$p>`=NyT*-PebtWWaBYGnm^raQ+~a{_OnH{D0-qP|>X%94K@np!1UuDfsO4#`Ypigs19+9HyasY9A8=%JMC7-=E; z`d}ercksI+bRpd1dxdYjX^LqzBf=3Bg`@=&ZI6aC^9y1Gx{Tp_fj@{CJf=5>O`deQ zDl_pl2rcQ&18jWl$4OgCpq!#R`gvG9(_pJDtbK92XssQ@S0_fVd}OYc0Bf?Yx6j?nY{vGfL@X1ps$W;6j{;l3eQ*~)C<(hc2M;}sdXlH zgUpsTs8f=4NiCzSeS=X!^D3E>5nrq6sf9AMo1HV|?_tUP#^jD*%3p`k8kCp_=_3f3t?zv<6=ztFG<| z**6Bes0;`{V68F-mmDNcnvnqEnkxG z!^XMglM+WjL21S0gdYlLRKAvY9ZC+1Ug>@dT*?@7AwI!d$IZYrrcxi&CtS~Ppk+v9X}cfCjaVT=KH^Q*f^&uL zs0tu7#<^2%fsCxco7NP_=@r^&kAnQp;Z1fFYV8$?+s&bNek9v|4Qj0nKV|1YPFd($ z>qLlr>T9bHhR_kt8`_4zAp0==Fa)~TXQ=Mr+-A*GYk?R=M1c{2|2I56-%hjV)ZaDa z|4)D43V`2g=MtCSJ4;|9-?9$eF6LU!h3J5UZIxd_j5drLA}R$v<6kAZlXP?);vW=(C4X% z91QA3wMcaXy-t;KH0TWFryuCHDo+grYmnNPp`gFf7pa@UZmpi-0kE#tpQ^{eYNxK} zR3>54pyuA<$zSdNvq!^B zU{9Wg>f!Mh&D~J8JobZE3FU)h&$x@B{H@rR-h3$gBs$%j1r>+HLf$$k|1y4zmw<{D zvC};ZDxQiR?rA6=7w_pgP+5~%^V|Rc1SCmBK~z6B%BuqvFUBW&?V#%1c%C^5s_sur z^oBunQeuD^57EoqiQY_zO>@_oD8zcY&1nL0+^fw2koea7-W(0?OT0m&t2?*{tn2kM^(@%?>3!4( z;9%XUz5(AvYbh(h*V(G4{NR7U>Z$h!e-HaPbtVLd*gf^#5PZ_vtUiSBsZLKlA402r zBlQM|40JxyaR^`L`$l^ZIm$OvyAZz6w@TX(S>#L8^&oPjue0s|kz;+GbvsBK<3Go0 z1(9aHHaY~s5%w`^7*Nc`oc;fJUh(AfO3hmPhphDflD}^Yz;E?ihY0?XisTT8H<(Ky zP@mQ`0pF+gy{ZRf9c{g(M??B4{t5bS@NCnIC&2x{^fDtMe!qK%IU1t1+?Hk;RGkuE z<@JG*vC(6^YoREwD&{VS!Z)j4bW5PPxO#@W2b4TgeX9E!lr4`w;XMu&%i{gK45&OH zw$VEeDtjf`dIOT$2sw1mWTj$$luw)e0Z z1YXDtVk4-*stwD*gw3O9w)X_3GKGHr@mVk-gb+dqA^tHE$tBR#_xf;63$Plj+K>+_ z#B1yidXbvWS)ixuOVn7fd+AcO80_2iOw|f}msuNB9DH|KT~&PuOtXinlOVX>xkAl> z&=7m7ehfkf_-gA62)A)w)^j245&uj2HOMFpd}xh_%n5<5)*{Hx4<@V($oeXHoYfRE zF7=q`{^Wy#SIJKf-9Hqpk5sai zUc=SEnbf?(`Ty}bx|e}L@<-hBEA5# z)Xb#|m>3mi5_p|Vd$kDMDQ1Xj1&Jx%mpl)Npf}9S26rFtIX(hwfSSMxaElm<4SFw@ z(*{gTouu{vt~4{vRe(blLEwwz{YLTdkRgCV2qAZ7l#b-|L;^_x!z*-}~Fp zj#{7|RQszvK<~}-yaj3wP00r5cs=UL)3l{IM8XY z(jYY5sZe`BWR`EPz6#Qt`xjY*A+vixSvinBD%joX3At`)lzkuMT^}B1odcQaff?5I z5WLr(pzZ{Q(;|5XU(hl6?u;Got0#-Ly?+pBn|$APr~CJI+O6PU7U2Jy{{vDA(?Wmr R=-2=N002ovPDHLkV1gsULeu~N literal 0 HcmV?d00001 diff --git a/js/cose-base.js b/js/cose-base.js new file mode 100755 index 0000000..49ccc66 --- /dev/null +++ b/js/cose-base.js @@ -0,0 +1,3214 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(require("layout-base")); + else if(typeof define === 'function' && define.amd) + define(["layout-base"], factory); + else if(typeof exports === 'object') + exports["coseBase"] = factory(require("layout-base")); + else + root["coseBase"] = factory(root["layoutBase"]); +})(this, function(__WEBPACK_EXTERNAL_MODULE__551__) { +return /******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 45: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var coseBase = {}; + +coseBase.layoutBase = __webpack_require__(551); +coseBase.CoSEConstants = __webpack_require__(806); +coseBase.CoSEEdge = __webpack_require__(767); +coseBase.CoSEGraph = __webpack_require__(880); +coseBase.CoSEGraphManager = __webpack_require__(578); +coseBase.CoSELayout = __webpack_require__(765); +coseBase.CoSENode = __webpack_require__(991); +coseBase.ConstraintHandler = __webpack_require__(902); + +module.exports = coseBase; + +/***/ }), + +/***/ 806: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var FDLayoutConstants = __webpack_require__(551).FDLayoutConstants; + +function CoSEConstants() {} + +//CoSEConstants inherits static props in FDLayoutConstants +for (var prop in FDLayoutConstants) { + CoSEConstants[prop] = FDLayoutConstants[prop]; +} + +CoSEConstants.DEFAULT_USE_MULTI_LEVEL_SCALING = false; +CoSEConstants.DEFAULT_RADIAL_SEPARATION = FDLayoutConstants.DEFAULT_EDGE_LENGTH; +CoSEConstants.DEFAULT_COMPONENT_SEPERATION = 60; +CoSEConstants.TILE = true; +CoSEConstants.TILING_PADDING_VERTICAL = 10; +CoSEConstants.TILING_PADDING_HORIZONTAL = 10; +CoSEConstants.TRANSFORM_ON_CONSTRAINT_HANDLING = true; +CoSEConstants.ENFORCE_CONSTRAINTS = true; +CoSEConstants.APPLY_LAYOUT = true; +CoSEConstants.RELAX_MOVEMENT_ON_CONSTRAINTS = true; +CoSEConstants.TREE_REDUCTION_ON_INCREMENTAL = true; // this should be set to false if there will be a constraint +// This constant is for differentiating whether actual layout algorithm that uses cose-base wants to apply only incremental layout or +// an incremental layout on top of a randomized layout. If it is only incremental layout, then this constant should be true. +CoSEConstants.PURE_INCREMENTAL = CoSEConstants.DEFAULT_INCREMENTAL; + +module.exports = CoSEConstants; + +/***/ }), + +/***/ 767: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var FDLayoutEdge = __webpack_require__(551).FDLayoutEdge; + +function CoSEEdge(source, target, vEdge) { + FDLayoutEdge.call(this, source, target, vEdge); +} + +CoSEEdge.prototype = Object.create(FDLayoutEdge.prototype); +for (var prop in FDLayoutEdge) { + CoSEEdge[prop] = FDLayoutEdge[prop]; +} + +module.exports = CoSEEdge; + +/***/ }), + +/***/ 880: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var LGraph = __webpack_require__(551).LGraph; + +function CoSEGraph(parent, graphMgr, vGraph) { + LGraph.call(this, parent, graphMgr, vGraph); +} + +CoSEGraph.prototype = Object.create(LGraph.prototype); +for (var prop in LGraph) { + CoSEGraph[prop] = LGraph[prop]; +} + +module.exports = CoSEGraph; + +/***/ }), + +/***/ 578: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var LGraphManager = __webpack_require__(551).LGraphManager; + +function CoSEGraphManager(layout) { + LGraphManager.call(this, layout); +} + +CoSEGraphManager.prototype = Object.create(LGraphManager.prototype); +for (var prop in LGraphManager) { + CoSEGraphManager[prop] = LGraphManager[prop]; +} + +module.exports = CoSEGraphManager; + +/***/ }), + +/***/ 765: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var FDLayout = __webpack_require__(551).FDLayout; +var CoSEGraphManager = __webpack_require__(578); +var CoSEGraph = __webpack_require__(880); +var CoSENode = __webpack_require__(991); +var CoSEEdge = __webpack_require__(767); +var CoSEConstants = __webpack_require__(806); +var ConstraintHandler = __webpack_require__(902); +var FDLayoutConstants = __webpack_require__(551).FDLayoutConstants; +var LayoutConstants = __webpack_require__(551).LayoutConstants; +var Point = __webpack_require__(551).Point; +var PointD = __webpack_require__(551).PointD; +var DimensionD = __webpack_require__(551).DimensionD; +var Layout = __webpack_require__(551).Layout; +var Integer = __webpack_require__(551).Integer; +var IGeometry = __webpack_require__(551).IGeometry; +var LGraph = __webpack_require__(551).LGraph; +var Transform = __webpack_require__(551).Transform; +var LinkedList = __webpack_require__(551).LinkedList; + +function CoSELayout() { + FDLayout.call(this); + + this.toBeTiled = {}; // Memorize if a node is to be tiled or is tiled + this.constraints = {}; // keep layout constraints +} + +CoSELayout.prototype = Object.create(FDLayout.prototype); + +for (var prop in FDLayout) { + CoSELayout[prop] = FDLayout[prop]; +} + +CoSELayout.prototype.newGraphManager = function () { + var gm = new CoSEGraphManager(this); + this.graphManager = gm; + return gm; +}; + +CoSELayout.prototype.newGraph = function (vGraph) { + return new CoSEGraph(null, this.graphManager, vGraph); +}; + +CoSELayout.prototype.newNode = function (vNode) { + return new CoSENode(this.graphManager, vNode); +}; + +CoSELayout.prototype.newEdge = function (vEdge) { + return new CoSEEdge(null, null, vEdge); +}; + +CoSELayout.prototype.initParameters = function () { + FDLayout.prototype.initParameters.call(this, arguments); + if (!this.isSubLayout) { + if (CoSEConstants.DEFAULT_EDGE_LENGTH < 10) { + this.idealEdgeLength = 10; + } else { + this.idealEdgeLength = CoSEConstants.DEFAULT_EDGE_LENGTH; + } + + this.useSmartIdealEdgeLengthCalculation = CoSEConstants.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION; + this.gravityConstant = FDLayoutConstants.DEFAULT_GRAVITY_STRENGTH; + this.compoundGravityConstant = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH; + this.gravityRangeFactor = FDLayoutConstants.DEFAULT_GRAVITY_RANGE_FACTOR; + this.compoundGravityRangeFactor = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR; + + // variables for tree reduction support + this.prunedNodesAll = []; + this.growTreeIterations = 0; + this.afterGrowthIterations = 0; + this.isTreeGrowing = false; + this.isGrowthFinished = false; + } +}; + +// This method is used to set CoSE related parameters used by spring embedder. +CoSELayout.prototype.initSpringEmbedder = function () { + FDLayout.prototype.initSpringEmbedder.call(this); + + // variables for cooling + this.coolingCycle = 0; + this.maxCoolingCycle = this.maxIterations / FDLayoutConstants.CONVERGENCE_CHECK_PERIOD; + this.finalTemperature = 0.04; + this.coolingAdjuster = 1; +}; + +CoSELayout.prototype.layout = function () { + var createBendsAsNeeded = LayoutConstants.DEFAULT_CREATE_BENDS_AS_NEEDED; + if (createBendsAsNeeded) { + this.createBendpoints(); + this.graphManager.resetAllEdges(); + } + + this.level = 0; + return this.classicLayout(); +}; + +CoSELayout.prototype.classicLayout = function () { + this.nodesWithGravity = this.calculateNodesToApplyGravitationTo(); + this.graphManager.setAllNodesToApplyGravitation(this.nodesWithGravity); + this.calcNoOfChildrenForAllNodes(); + this.graphManager.calcLowestCommonAncestors(); + this.graphManager.calcInclusionTreeDepths(); + this.graphManager.getRoot().calcEstimatedSize(); + this.calcIdealEdgeLengths(); + + if (!this.incremental) { + var forest = this.getFlatForest(); + + // The graph associated with this layout is flat and a forest + if (forest.length > 0) { + this.positionNodesRadially(forest); + } + // The graph associated with this layout is not flat or a forest + else { + // Reduce the trees when incremental mode is not enabled and graph is not a forest + this.reduceTrees(); + // Update nodes that gravity will be applied + this.graphManager.resetAllNodesToApplyGravitation(); + var allNodes = new Set(this.getAllNodes()); + var intersection = this.nodesWithGravity.filter(function (x) { + return allNodes.has(x); + }); + this.graphManager.setAllNodesToApplyGravitation(intersection); + + this.positionNodesRandomly(); + } + } else { + if (CoSEConstants.TREE_REDUCTION_ON_INCREMENTAL) { + // Reduce the trees in incremental mode if only this constant is set to true + this.reduceTrees(); + // Update nodes that gravity will be applied + this.graphManager.resetAllNodesToApplyGravitation(); + var allNodes = new Set(this.getAllNodes()); + var intersection = this.nodesWithGravity.filter(function (x) { + return allNodes.has(x); + }); + this.graphManager.setAllNodesToApplyGravitation(intersection); + } + } + + if (Object.keys(this.constraints).length > 0) { + ConstraintHandler.handleConstraints(this); + this.initConstraintVariables(); + } + + this.initSpringEmbedder(); + if (CoSEConstants.APPLY_LAYOUT) { + this.runSpringEmbedder(); + } + + return true; +}; + +CoSELayout.prototype.tick = function () { + this.totalIterations++; + + if (this.totalIterations === this.maxIterations && !this.isTreeGrowing && !this.isGrowthFinished) { + if (this.prunedNodesAll.length > 0) { + this.isTreeGrowing = true; + } else { + return true; + } + } + + if (this.totalIterations % FDLayoutConstants.CONVERGENCE_CHECK_PERIOD == 0 && !this.isTreeGrowing && !this.isGrowthFinished) { + if (this.isConverged()) { + if (this.prunedNodesAll.length > 0) { + this.isTreeGrowing = true; + } else { + return true; + } + } + + this.coolingCycle++; + + if (this.layoutQuality == 0) { + // quality - "draft" + this.coolingAdjuster = this.coolingCycle; + } else if (this.layoutQuality == 1) { + // quality - "default" + this.coolingAdjuster = this.coolingCycle / 3; + } + + // cooling schedule is based on http://www.btluke.com/simanf1.html -> cooling schedule 3 + this.coolingFactor = Math.max(this.initialCoolingFactor - Math.pow(this.coolingCycle, Math.log(100 * (this.initialCoolingFactor - this.finalTemperature)) / Math.log(this.maxCoolingCycle)) / 100 * this.coolingAdjuster, this.finalTemperature); + this.animationPeriod = Math.ceil(this.initialAnimationPeriod * Math.sqrt(this.coolingFactor)); + } + // Operations while tree is growing again + if (this.isTreeGrowing) { + if (this.growTreeIterations % 10 == 0) { + if (this.prunedNodesAll.length > 0) { + this.graphManager.updateBounds(); + this.updateGrid(); + this.growTree(this.prunedNodesAll); + // Update nodes that gravity will be applied + this.graphManager.resetAllNodesToApplyGravitation(); + var allNodes = new Set(this.getAllNodes()); + var intersection = this.nodesWithGravity.filter(function (x) { + return allNodes.has(x); + }); + this.graphManager.setAllNodesToApplyGravitation(intersection); + + this.graphManager.updateBounds(); + this.updateGrid(); + if (CoSEConstants.PURE_INCREMENTAL) this.coolingFactor = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL / 2;else this.coolingFactor = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL; + } else { + this.isTreeGrowing = false; + this.isGrowthFinished = true; + } + } + this.growTreeIterations++; + } + // Operations after growth is finished + if (this.isGrowthFinished) { + if (this.isConverged()) { + return true; + } + if (this.afterGrowthIterations % 10 == 0) { + this.graphManager.updateBounds(); + this.updateGrid(); + } + if (CoSEConstants.PURE_INCREMENTAL) this.coolingFactor = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL / 2 * ((100 - this.afterGrowthIterations) / 100);else this.coolingFactor = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL * ((100 - this.afterGrowthIterations) / 100); + this.afterGrowthIterations++; + } + + var gridUpdateAllowed = !this.isTreeGrowing && !this.isGrowthFinished; + var forceToNodeSurroundingUpdate = this.growTreeIterations % 10 == 1 && this.isTreeGrowing || this.afterGrowthIterations % 10 == 1 && this.isGrowthFinished; + + this.totalDisplacement = 0; + this.graphManager.updateBounds(); + this.calcSpringForces(); + this.calcRepulsionForces(gridUpdateAllowed, forceToNodeSurroundingUpdate); + this.calcGravitationalForces(); + this.moveNodes(); + this.animate(); + + return false; // Layout is not ended yet return false +}; + +CoSELayout.prototype.getPositionsData = function () { + var allNodes = this.graphManager.getAllNodes(); + var pData = {}; + for (var i = 0; i < allNodes.length; i++) { + var rect = allNodes[i].rect; + var id = allNodes[i].id; + pData[id] = { + id: id, + x: rect.getCenterX(), + y: rect.getCenterY(), + w: rect.width, + h: rect.height + }; + } + + return pData; +}; + +CoSELayout.prototype.runSpringEmbedder = function () { + this.initialAnimationPeriod = 25; + this.animationPeriod = this.initialAnimationPeriod; + var layoutEnded = false; + + // If aminate option is 'during' signal that layout is supposed to start iterating + if (FDLayoutConstants.ANIMATE === 'during') { + this.emit('layoutstarted'); + } else { + // If aminate option is 'during' tick() function will be called on index.js + while (!layoutEnded) { + layoutEnded = this.tick(); + } + + this.graphManager.updateBounds(); + } +}; + +// overrides moveNodes method in FDLayout +CoSELayout.prototype.moveNodes = function () { + var lNodes = this.getAllNodes(); + var node; + + // calculate displacement for each node + for (var i = 0; i < lNodes.length; i++) { + node = lNodes[i]; + node.calculateDisplacement(); + } + + if (Object.keys(this.constraints).length > 0) { + this.updateDisplacements(); + } + + // move each node + for (var i = 0; i < lNodes.length; i++) { + node = lNodes[i]; + node.move(); + } +}; + +// constraint related methods: initConstraintVariables and updateDisplacements + +// initialize constraint related variables +CoSELayout.prototype.initConstraintVariables = function () { + var self = this; + this.idToNodeMap = new Map(); + this.fixedNodeSet = new Set(); + + var allNodes = this.graphManager.getAllNodes(); + + // fill idToNodeMap + for (var i = 0; i < allNodes.length; i++) { + var node = allNodes[i]; + this.idToNodeMap.set(node.id, node); + } + + // calculate fixed node weight for given compound node + var calculateCompoundWeight = function calculateCompoundWeight(compoundNode) { + var nodes = compoundNode.getChild().getNodes(); + var node; + var fixedNodeWeight = 0; + for (var i = 0; i < nodes.length; i++) { + node = nodes[i]; + if (node.getChild() == null) { + if (self.fixedNodeSet.has(node.id)) { + fixedNodeWeight += 100; + } + } else { + fixedNodeWeight += calculateCompoundWeight(node); + } + } + return fixedNodeWeight; + }; + + if (this.constraints.fixedNodeConstraint) { + // fill fixedNodeSet + this.constraints.fixedNodeConstraint.forEach(function (nodeData) { + self.fixedNodeSet.add(nodeData.nodeId); + }); + + // assign fixed node weights to compounds if they contain fixed nodes + var allNodes = this.graphManager.getAllNodes(); + var node; + + for (var i = 0; i < allNodes.length; i++) { + node = allNodes[i]; + if (node.getChild() != null) { + var fixedNodeWeight = calculateCompoundWeight(node); + if (fixedNodeWeight > 0) { + node.fixedNodeWeight = fixedNodeWeight; + } + } + } + } + + if (this.constraints.relativePlacementConstraint) { + var nodeToDummyForVerticalAlignment = new Map(); + var nodeToDummyForHorizontalAlignment = new Map(); + this.dummyToNodeForVerticalAlignment = new Map(); + this.dummyToNodeForHorizontalAlignment = new Map(); + this.fixedNodesOnHorizontal = new Set(); + this.fixedNodesOnVertical = new Set(); + + // fill maps and sets + this.fixedNodeSet.forEach(function (nodeId) { + self.fixedNodesOnHorizontal.add(nodeId); + self.fixedNodesOnVertical.add(nodeId); + }); + + if (this.constraints.alignmentConstraint) { + if (this.constraints.alignmentConstraint.vertical) { + var verticalAlignment = this.constraints.alignmentConstraint.vertical; + for (var i = 0; i < verticalAlignment.length; i++) { + this.dummyToNodeForVerticalAlignment.set("dummy" + i, []); + verticalAlignment[i].forEach(function (nodeId) { + nodeToDummyForVerticalAlignment.set(nodeId, "dummy" + i); + self.dummyToNodeForVerticalAlignment.get("dummy" + i).push(nodeId); + if (self.fixedNodeSet.has(nodeId)) { + self.fixedNodesOnHorizontal.add("dummy" + i); + } + }); + } + } + if (this.constraints.alignmentConstraint.horizontal) { + var horizontalAlignment = this.constraints.alignmentConstraint.horizontal; + for (var i = 0; i < horizontalAlignment.length; i++) { + this.dummyToNodeForHorizontalAlignment.set("dummy" + i, []); + horizontalAlignment[i].forEach(function (nodeId) { + nodeToDummyForHorizontalAlignment.set(nodeId, "dummy" + i); + self.dummyToNodeForHorizontalAlignment.get("dummy" + i).push(nodeId); + if (self.fixedNodeSet.has(nodeId)) { + self.fixedNodesOnVertical.add("dummy" + i); + } + }); + } + } + } + + if (CoSEConstants.RELAX_MOVEMENT_ON_CONSTRAINTS) { + + this.shuffle = function (array) { + var j, x, i; + for (i = array.length - 1; i >= 2 * array.length / 3; i--) { + j = Math.floor(Math.random() * (i + 1)); + x = array[i]; + array[i] = array[j]; + array[j] = x; + } + return array; + }; + + this.nodesInRelativeHorizontal = []; + this.nodesInRelativeVertical = []; + this.nodeToRelativeConstraintMapHorizontal = new Map(); + this.nodeToRelativeConstraintMapVertical = new Map(); + this.nodeToTempPositionMapHorizontal = new Map(); + this.nodeToTempPositionMapVertical = new Map(); + + // fill arrays and maps + this.constraints.relativePlacementConstraint.forEach(function (constraint) { + if (constraint.left) { + var nodeIdLeft = nodeToDummyForVerticalAlignment.has(constraint.left) ? nodeToDummyForVerticalAlignment.get(constraint.left) : constraint.left; + var nodeIdRight = nodeToDummyForVerticalAlignment.has(constraint.right) ? nodeToDummyForVerticalAlignment.get(constraint.right) : constraint.right; + + if (!self.nodesInRelativeHorizontal.includes(nodeIdLeft)) { + self.nodesInRelativeHorizontal.push(nodeIdLeft); + self.nodeToRelativeConstraintMapHorizontal.set(nodeIdLeft, []); + if (self.dummyToNodeForVerticalAlignment.has(nodeIdLeft)) { + self.nodeToTempPositionMapHorizontal.set(nodeIdLeft, self.idToNodeMap.get(self.dummyToNodeForVerticalAlignment.get(nodeIdLeft)[0]).getCenterX()); + } else { + self.nodeToTempPositionMapHorizontal.set(nodeIdLeft, self.idToNodeMap.get(nodeIdLeft).getCenterX()); + } + } + if (!self.nodesInRelativeHorizontal.includes(nodeIdRight)) { + self.nodesInRelativeHorizontal.push(nodeIdRight); + self.nodeToRelativeConstraintMapHorizontal.set(nodeIdRight, []); + if (self.dummyToNodeForVerticalAlignment.has(nodeIdRight)) { + self.nodeToTempPositionMapHorizontal.set(nodeIdRight, self.idToNodeMap.get(self.dummyToNodeForVerticalAlignment.get(nodeIdRight)[0]).getCenterX()); + } else { + self.nodeToTempPositionMapHorizontal.set(nodeIdRight, self.idToNodeMap.get(nodeIdRight).getCenterX()); + } + } + + self.nodeToRelativeConstraintMapHorizontal.get(nodeIdLeft).push({ right: nodeIdRight, gap: constraint.gap }); + self.nodeToRelativeConstraintMapHorizontal.get(nodeIdRight).push({ left: nodeIdLeft, gap: constraint.gap }); + } else { + var nodeIdTop = nodeToDummyForHorizontalAlignment.has(constraint.top) ? nodeToDummyForHorizontalAlignment.get(constraint.top) : constraint.top; + var nodeIdBottom = nodeToDummyForHorizontalAlignment.has(constraint.bottom) ? nodeToDummyForHorizontalAlignment.get(constraint.bottom) : constraint.bottom; + + if (!self.nodesInRelativeVertical.includes(nodeIdTop)) { + self.nodesInRelativeVertical.push(nodeIdTop); + self.nodeToRelativeConstraintMapVertical.set(nodeIdTop, []); + if (self.dummyToNodeForHorizontalAlignment.has(nodeIdTop)) { + self.nodeToTempPositionMapVertical.set(nodeIdTop, self.idToNodeMap.get(self.dummyToNodeForHorizontalAlignment.get(nodeIdTop)[0]).getCenterY()); + } else { + self.nodeToTempPositionMapVertical.set(nodeIdTop, self.idToNodeMap.get(nodeIdTop).getCenterY()); + } + } + if (!self.nodesInRelativeVertical.includes(nodeIdBottom)) { + self.nodesInRelativeVertical.push(nodeIdBottom); + self.nodeToRelativeConstraintMapVertical.set(nodeIdBottom, []); + if (self.dummyToNodeForHorizontalAlignment.has(nodeIdBottom)) { + self.nodeToTempPositionMapVertical.set(nodeIdBottom, self.idToNodeMap.get(self.dummyToNodeForHorizontalAlignment.get(nodeIdBottom)[0]).getCenterY()); + } else { + self.nodeToTempPositionMapVertical.set(nodeIdBottom, self.idToNodeMap.get(nodeIdBottom).getCenterY()); + } + } + self.nodeToRelativeConstraintMapVertical.get(nodeIdTop).push({ bottom: nodeIdBottom, gap: constraint.gap }); + self.nodeToRelativeConstraintMapVertical.get(nodeIdBottom).push({ top: nodeIdTop, gap: constraint.gap }); + } + }); + } else { + var subGraphOnHorizontal = new Map(); // subgraph from vertical RP constraints + var subGraphOnVertical = new Map(); // subgraph from vertical RP constraints + + // construct subgraphs from relative placement constraints + this.constraints.relativePlacementConstraint.forEach(function (constraint) { + if (constraint.left) { + var left = nodeToDummyForVerticalAlignment.has(constraint.left) ? nodeToDummyForVerticalAlignment.get(constraint.left) : constraint.left; + var right = nodeToDummyForVerticalAlignment.has(constraint.right) ? nodeToDummyForVerticalAlignment.get(constraint.right) : constraint.right; + if (subGraphOnHorizontal.has(left)) { + subGraphOnHorizontal.get(left).push(right); + } else { + subGraphOnHorizontal.set(left, [right]); + } + if (subGraphOnHorizontal.has(right)) { + subGraphOnHorizontal.get(right).push(left); + } else { + subGraphOnHorizontal.set(right, [left]); + } + } else { + var top = nodeToDummyForHorizontalAlignment.has(constraint.top) ? nodeToDummyForHorizontalAlignment.get(constraint.top) : constraint.top; + var bottom = nodeToDummyForHorizontalAlignment.has(constraint.bottom) ? nodeToDummyForHorizontalAlignment.get(constraint.bottom) : constraint.bottom; + if (subGraphOnVertical.has(top)) { + subGraphOnVertical.get(top).push(bottom); + } else { + subGraphOnVertical.set(top, [bottom]); + } + if (subGraphOnVertical.has(bottom)) { + subGraphOnVertical.get(bottom).push(top); + } else { + subGraphOnVertical.set(bottom, [top]); + } + } + }); + + // function to construct components from a given graph + // also returns an array that keeps whether each component contains fixed node + var constructComponents = function constructComponents(graph, fixedNodes) { + var components = []; + var isFixed = []; + var queue = new LinkedList(); + var visited = new Set(); + var count = 0; + + graph.forEach(function (value, key) { + if (!visited.has(key)) { + components[count] = []; + isFixed[count] = false; + var currentNode = key; + queue.push(currentNode); + visited.add(currentNode); + components[count].push(currentNode); + + while (queue.length != 0) { + currentNode = queue.shift(); + if (fixedNodes.has(currentNode)) { + isFixed[count] = true; + } + var neighbors = graph.get(currentNode); + neighbors.forEach(function (neighbor) { + if (!visited.has(neighbor)) { + queue.push(neighbor); + visited.add(neighbor); + components[count].push(neighbor); + } + }); + } + count++; + } + }); + + return { components: components, isFixed: isFixed }; + }; + + var resultOnHorizontal = constructComponents(subGraphOnHorizontal, self.fixedNodesOnHorizontal); + this.componentsOnHorizontal = resultOnHorizontal.components; + this.fixedComponentsOnHorizontal = resultOnHorizontal.isFixed; + var resultOnVertical = constructComponents(subGraphOnVertical, self.fixedNodesOnVertical); + this.componentsOnVertical = resultOnVertical.components; + this.fixedComponentsOnVertical = resultOnVertical.isFixed; + } + } +}; + +// updates node displacements based on constraints +CoSELayout.prototype.updateDisplacements = function () { + var self = this; + if (this.constraints.fixedNodeConstraint) { + this.constraints.fixedNodeConstraint.forEach(function (nodeData) { + var fixedNode = self.idToNodeMap.get(nodeData.nodeId); + fixedNode.displacementX = 0; + fixedNode.displacementY = 0; + }); + } + + if (this.constraints.alignmentConstraint) { + if (this.constraints.alignmentConstraint.vertical) { + var allVerticalAlignments = this.constraints.alignmentConstraint.vertical; + for (var i = 0; i < allVerticalAlignments.length; i++) { + var totalDisplacementX = 0; + for (var j = 0; j < allVerticalAlignments[i].length; j++) { + if (this.fixedNodeSet.has(allVerticalAlignments[i][j])) { + totalDisplacementX = 0; + break; + } + totalDisplacementX += this.idToNodeMap.get(allVerticalAlignments[i][j]).displacementX; + } + var averageDisplacementX = totalDisplacementX / allVerticalAlignments[i].length; + for (var j = 0; j < allVerticalAlignments[i].length; j++) { + this.idToNodeMap.get(allVerticalAlignments[i][j]).displacementX = averageDisplacementX; + } + } + } + if (this.constraints.alignmentConstraint.horizontal) { + var allHorizontalAlignments = this.constraints.alignmentConstraint.horizontal; + for (var i = 0; i < allHorizontalAlignments.length; i++) { + var totalDisplacementY = 0; + for (var j = 0; j < allHorizontalAlignments[i].length; j++) { + if (this.fixedNodeSet.has(allHorizontalAlignments[i][j])) { + totalDisplacementY = 0; + break; + } + totalDisplacementY += this.idToNodeMap.get(allHorizontalAlignments[i][j]).displacementY; + } + var averageDisplacementY = totalDisplacementY / allHorizontalAlignments[i].length; + for (var j = 0; j < allHorizontalAlignments[i].length; j++) { + this.idToNodeMap.get(allHorizontalAlignments[i][j]).displacementY = averageDisplacementY; + } + } + } + } + + if (this.constraints.relativePlacementConstraint) { + + if (CoSEConstants.RELAX_MOVEMENT_ON_CONSTRAINTS) { + // shuffle array to randomize node processing order + if (this.totalIterations % 10 == 0) { + this.shuffle(this.nodesInRelativeHorizontal); + this.shuffle(this.nodesInRelativeVertical); + } + + this.nodesInRelativeHorizontal.forEach(function (nodeId) { + if (!self.fixedNodesOnHorizontal.has(nodeId)) { + var displacement = 0; + if (self.dummyToNodeForVerticalAlignment.has(nodeId)) { + displacement = self.idToNodeMap.get(self.dummyToNodeForVerticalAlignment.get(nodeId)[0]).displacementX; + } else { + displacement = self.idToNodeMap.get(nodeId).displacementX; + } + self.nodeToRelativeConstraintMapHorizontal.get(nodeId).forEach(function (constraint) { + if (constraint.right) { + var diff = self.nodeToTempPositionMapHorizontal.get(constraint.right) - self.nodeToTempPositionMapHorizontal.get(nodeId) - displacement; + if (diff < constraint.gap) { + displacement -= constraint.gap - diff; + } + } else { + var diff = self.nodeToTempPositionMapHorizontal.get(nodeId) - self.nodeToTempPositionMapHorizontal.get(constraint.left) + displacement; + if (diff < constraint.gap) { + displacement += constraint.gap - diff; + } + } + }); + self.nodeToTempPositionMapHorizontal.set(nodeId, self.nodeToTempPositionMapHorizontal.get(nodeId) + displacement); + if (self.dummyToNodeForVerticalAlignment.has(nodeId)) { + self.dummyToNodeForVerticalAlignment.get(nodeId).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementX = displacement; + }); + } else { + self.idToNodeMap.get(nodeId).displacementX = displacement; + } + } + }); + + this.nodesInRelativeVertical.forEach(function (nodeId) { + if (!self.fixedNodesOnHorizontal.has(nodeId)) { + var displacement = 0; + if (self.dummyToNodeForHorizontalAlignment.has(nodeId)) { + displacement = self.idToNodeMap.get(self.dummyToNodeForHorizontalAlignment.get(nodeId)[0]).displacementY; + } else { + displacement = self.idToNodeMap.get(nodeId).displacementY; + } + self.nodeToRelativeConstraintMapVertical.get(nodeId).forEach(function (constraint) { + if (constraint.bottom) { + var diff = self.nodeToTempPositionMapVertical.get(constraint.bottom) - self.nodeToTempPositionMapVertical.get(nodeId) - displacement; + if (diff < constraint.gap) { + displacement -= constraint.gap - diff; + } + } else { + var diff = self.nodeToTempPositionMapVertical.get(nodeId) - self.nodeToTempPositionMapVertical.get(constraint.top) + displacement; + if (diff < constraint.gap) { + displacement += constraint.gap - diff; + } + } + }); + self.nodeToTempPositionMapVertical.set(nodeId, self.nodeToTempPositionMapVertical.get(nodeId) + displacement); + if (self.dummyToNodeForHorizontalAlignment.has(nodeId)) { + self.dummyToNodeForHorizontalAlignment.get(nodeId).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementY = displacement; + }); + } else { + self.idToNodeMap.get(nodeId).displacementY = displacement; + } + } + }); + } else { + for (var i = 0; i < this.componentsOnHorizontal.length; i++) { + var component = this.componentsOnHorizontal[i]; + if (this.fixedComponentsOnHorizontal[i]) { + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForVerticalAlignment.has(component[j])) { + this.dummyToNodeForVerticalAlignment.get(component[j]).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementX = 0; + }); + } else { + this.idToNodeMap.get(component[j]).displacementX = 0; + } + } + } else { + var sum = 0; + var count = 0; + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForVerticalAlignment.has(component[j])) { + var actualNodes = this.dummyToNodeForVerticalAlignment.get(component[j]); + sum += actualNodes.length * this.idToNodeMap.get(actualNodes[0]).displacementX; + count += actualNodes.length; + } else { + sum += this.idToNodeMap.get(component[j]).displacementX; + count++; + } + } + var averageDisplacement = sum / count; + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForVerticalAlignment.has(component[j])) { + this.dummyToNodeForVerticalAlignment.get(component[j]).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementX = averageDisplacement; + }); + } else { + this.idToNodeMap.get(component[j]).displacementX = averageDisplacement; + } + } + } + } + + for (var i = 0; i < this.componentsOnVertical.length; i++) { + var component = this.componentsOnVertical[i]; + if (this.fixedComponentsOnVertical[i]) { + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForHorizontalAlignment.has(component[j])) { + this.dummyToNodeForHorizontalAlignment.get(component[j]).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementY = 0; + }); + } else { + this.idToNodeMap.get(component[j]).displacementY = 0; + } + } + } else { + var sum = 0; + var count = 0; + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForHorizontalAlignment.has(component[j])) { + var actualNodes = this.dummyToNodeForHorizontalAlignment.get(component[j]); + sum += actualNodes.length * this.idToNodeMap.get(actualNodes[0]).displacementY; + count += actualNodes.length; + } else { + sum += this.idToNodeMap.get(component[j]).displacementY; + count++; + } + } + var averageDisplacement = sum / count; + for (var j = 0; j < component.length; j++) { + if (this.dummyToNodeForHorizontalAlignment.has(component[j])) { + this.dummyToNodeForHorizontalAlignment.get(component[j]).forEach(function (nodeId) { + self.idToNodeMap.get(nodeId).displacementY = averageDisplacement; + }); + } else { + this.idToNodeMap.get(component[j]).displacementY = averageDisplacement; + } + } + } + } + } + } +}; + +CoSELayout.prototype.calculateNodesToApplyGravitationTo = function () { + var nodeList = []; + var graph; + + var graphs = this.graphManager.getGraphs(); + var size = graphs.length; + var i; + for (i = 0; i < size; i++) { + graph = graphs[i]; + + graph.updateConnected(); + + if (!graph.isConnected) { + nodeList = nodeList.concat(graph.getNodes()); + } + } + + return nodeList; +}; + +CoSELayout.prototype.createBendpoints = function () { + var edges = []; + edges = edges.concat(this.graphManager.getAllEdges()); + var visited = new Set(); + var i; + for (i = 0; i < edges.length; i++) { + var edge = edges[i]; + + if (!visited.has(edge)) { + var source = edge.getSource(); + var target = edge.getTarget(); + + if (source == target) { + edge.getBendpoints().push(new PointD()); + edge.getBendpoints().push(new PointD()); + this.createDummyNodesForBendpoints(edge); + visited.add(edge); + } else { + var edgeList = []; + + edgeList = edgeList.concat(source.getEdgeListToNode(target)); + edgeList = edgeList.concat(target.getEdgeListToNode(source)); + + if (!visited.has(edgeList[0])) { + if (edgeList.length > 1) { + var k; + for (k = 0; k < edgeList.length; k++) { + var multiEdge = edgeList[k]; + multiEdge.getBendpoints().push(new PointD()); + this.createDummyNodesForBendpoints(multiEdge); + } + } + edgeList.forEach(function (edge) { + visited.add(edge); + }); + } + } + } + + if (visited.size == edges.length) { + break; + } + } +}; + +CoSELayout.prototype.positionNodesRadially = function (forest) { + // We tile the trees to a grid row by row; first tree starts at (0,0) + var currentStartingPoint = new Point(0, 0); + var numberOfColumns = Math.ceil(Math.sqrt(forest.length)); + var height = 0; + var currentY = 0; + var currentX = 0; + var point = new PointD(0, 0); + + for (var i = 0; i < forest.length; i++) { + if (i % numberOfColumns == 0) { + // Start of a new row, make the x coordinate 0, increment the + // y coordinate with the max height of the previous row + currentX = 0; + currentY = height; + + if (i != 0) { + currentY += CoSEConstants.DEFAULT_COMPONENT_SEPERATION; + } + + height = 0; + } + + var tree = forest[i]; + + // Find the center of the tree + var centerNode = Layout.findCenterOfTree(tree); + + // Set the staring point of the next tree + currentStartingPoint.x = currentX; + currentStartingPoint.y = currentY; + + // Do a radial layout starting with the center + point = CoSELayout.radialLayout(tree, centerNode, currentStartingPoint); + + if (point.y > height) { + height = Math.floor(point.y); + } + + currentX = Math.floor(point.x + CoSEConstants.DEFAULT_COMPONENT_SEPERATION); + } + + this.transform(new PointD(LayoutConstants.WORLD_CENTER_X - point.x / 2, LayoutConstants.WORLD_CENTER_Y - point.y / 2)); +}; + +CoSELayout.radialLayout = function (tree, centerNode, startingPoint) { + var radialSep = Math.max(this.maxDiagonalInTree(tree), CoSEConstants.DEFAULT_RADIAL_SEPARATION); + CoSELayout.branchRadialLayout(centerNode, null, 0, 359, 0, radialSep); + var bounds = LGraph.calculateBounds(tree); + + var transform = new Transform(); + transform.setDeviceOrgX(bounds.getMinX()); + transform.setDeviceOrgY(bounds.getMinY()); + transform.setWorldOrgX(startingPoint.x); + transform.setWorldOrgY(startingPoint.y); + + for (var i = 0; i < tree.length; i++) { + var node = tree[i]; + node.transform(transform); + } + + var bottomRight = new PointD(bounds.getMaxX(), bounds.getMaxY()); + + return transform.inverseTransformPoint(bottomRight); +}; + +CoSELayout.branchRadialLayout = function (node, parentOfNode, startAngle, endAngle, distance, radialSeparation) { + // First, position this node by finding its angle. + var halfInterval = (endAngle - startAngle + 1) / 2; + + if (halfInterval < 0) { + halfInterval += 180; + } + + var nodeAngle = (halfInterval + startAngle) % 360; + var teta = nodeAngle * IGeometry.TWO_PI / 360; + + // Make polar to java cordinate conversion. + var cos_teta = Math.cos(teta); + var x_ = distance * Math.cos(teta); + var y_ = distance * Math.sin(teta); + + node.setCenter(x_, y_); + + // Traverse all neighbors of this node and recursively call this + // function. + var neighborEdges = []; + neighborEdges = neighborEdges.concat(node.getEdges()); + var childCount = neighborEdges.length; + + if (parentOfNode != null) { + childCount--; + } + + var branchCount = 0; + + var incEdgesCount = neighborEdges.length; + var startIndex; + + var edges = node.getEdgesBetween(parentOfNode); + + // If there are multiple edges, prune them until there remains only one + // edge. + while (edges.length > 1) { + //neighborEdges.remove(edges.remove(0)); + var temp = edges[0]; + edges.splice(0, 1); + var index = neighborEdges.indexOf(temp); + if (index >= 0) { + neighborEdges.splice(index, 1); + } + incEdgesCount--; + childCount--; + } + + if (parentOfNode != null) { + //assert edges.length == 1; + startIndex = (neighborEdges.indexOf(edges[0]) + 1) % incEdgesCount; + } else { + startIndex = 0; + } + + var stepAngle = Math.abs(endAngle - startAngle) / childCount; + + for (var i = startIndex; branchCount != childCount; i = ++i % incEdgesCount) { + var currentNeighbor = neighborEdges[i].getOtherEnd(node); + + // Don't back traverse to root node in current tree. + if (currentNeighbor == parentOfNode) { + continue; + } + + var childStartAngle = (startAngle + branchCount * stepAngle) % 360; + var childEndAngle = (childStartAngle + stepAngle) % 360; + + CoSELayout.branchRadialLayout(currentNeighbor, node, childStartAngle, childEndAngle, distance + radialSeparation, radialSeparation); + + branchCount++; + } +}; + +CoSELayout.maxDiagonalInTree = function (tree) { + var maxDiagonal = Integer.MIN_VALUE; + + for (var i = 0; i < tree.length; i++) { + var node = tree[i]; + var diagonal = node.getDiagonal(); + + if (diagonal > maxDiagonal) { + maxDiagonal = diagonal; + } + } + + return maxDiagonal; +}; + +CoSELayout.prototype.calcRepulsionRange = function () { + // formula is 2 x (level + 1) x idealEdgeLength + return 2 * (this.level + 1) * this.idealEdgeLength; +}; + +// Tiling methods + +// Group zero degree members whose parents are not to be tiled, create dummy parents where needed and fill memberGroups by their dummp parent id's +CoSELayout.prototype.groupZeroDegreeMembers = function () { + var self = this; + // array of [parent_id x oneDegreeNode_id] + var tempMemberGroups = {}; // A temporary map of parent node and its zero degree members + this.memberGroups = {}; // A map of dummy parent node and its zero degree members whose parents are not to be tiled + this.idToDummyNode = {}; // A map of id to dummy node + + var zeroDegree = []; // List of zero degree nodes whose parents are not to be tiled + var allNodes = this.graphManager.getAllNodes(); + + // Fill zero degree list + for (var i = 0; i < allNodes.length; i++) { + var node = allNodes[i]; + var parent = node.getParent(); + // If a node has zero degree and its parent is not to be tiled if exists add that node to zeroDegres list + if (this.getNodeDegreeWithChildren(node) === 0 && (parent.id == undefined || !this.getToBeTiled(parent))) { + zeroDegree.push(node); + } + } + + // Create a map of parent node and its zero degree members + for (var i = 0; i < zeroDegree.length; i++) { + var node = zeroDegree[i]; // Zero degree node itself + var p_id = node.getParent().id; // Parent id + + if (typeof tempMemberGroups[p_id] === "undefined") tempMemberGroups[p_id] = []; + + tempMemberGroups[p_id] = tempMemberGroups[p_id].concat(node); // Push node to the list belongs to its parent in tempMemberGroups + } + + // If there are at least two nodes at a level, create a dummy compound for them + Object.keys(tempMemberGroups).forEach(function (p_id) { + if (tempMemberGroups[p_id].length > 1) { + var dummyCompoundId = "DummyCompound_" + p_id; // The id of dummy compound which will be created soon + self.memberGroups[dummyCompoundId] = tempMemberGroups[p_id]; // Add dummy compound to memberGroups + + var parent = tempMemberGroups[p_id][0].getParent(); // The parent of zero degree nodes will be the parent of new dummy compound + + // Create a dummy compound with calculated id + var dummyCompound = new CoSENode(self.graphManager); + dummyCompound.id = dummyCompoundId; + dummyCompound.paddingLeft = parent.paddingLeft || 0; + dummyCompound.paddingRight = parent.paddingRight || 0; + dummyCompound.paddingBottom = parent.paddingBottom || 0; + dummyCompound.paddingTop = parent.paddingTop || 0; + + self.idToDummyNode[dummyCompoundId] = dummyCompound; + + var dummyParentGraph = self.getGraphManager().add(self.newGraph(), dummyCompound); + var parentGraph = parent.getChild(); + + // Add dummy compound to parent the graph + parentGraph.add(dummyCompound); + + // For each zero degree node in this level remove it from its parent graph and add it to the graph of dummy parent + for (var i = 0; i < tempMemberGroups[p_id].length; i++) { + var node = tempMemberGroups[p_id][i]; + + parentGraph.remove(node); + dummyParentGraph.add(node); + } + } + }); +}; + +CoSELayout.prototype.clearCompounds = function () { + var childGraphMap = {}; + var idToNode = {}; + + // Get compound ordering by finding the inner one first + this.performDFSOnCompounds(); + + for (var i = 0; i < this.compoundOrder.length; i++) { + + idToNode[this.compoundOrder[i].id] = this.compoundOrder[i]; + childGraphMap[this.compoundOrder[i].id] = [].concat(this.compoundOrder[i].getChild().getNodes()); + + // Remove children of compounds + this.graphManager.remove(this.compoundOrder[i].getChild()); + this.compoundOrder[i].child = null; + } + + this.graphManager.resetAllNodes(); + + // Tile the removed children + this.tileCompoundMembers(childGraphMap, idToNode); +}; + +CoSELayout.prototype.clearZeroDegreeMembers = function () { + var self = this; + var tiledZeroDegreePack = this.tiledZeroDegreePack = []; + + Object.keys(this.memberGroups).forEach(function (id) { + var compoundNode = self.idToDummyNode[id]; // Get the dummy compound + + tiledZeroDegreePack[id] = self.tileNodes(self.memberGroups[id], compoundNode.paddingLeft + compoundNode.paddingRight); + + // Set the width and height of the dummy compound as calculated + compoundNode.rect.width = tiledZeroDegreePack[id].width; + compoundNode.rect.height = tiledZeroDegreePack[id].height; + compoundNode.setCenter(tiledZeroDegreePack[id].centerX, tiledZeroDegreePack[id].centerY); + + // compound left and top margings for labels + // when node labels are included, these values may be set to different values below and are used in tilingPostLayout, + // otherwise they stay as zero + compoundNode.labelMarginLeft = 0; + compoundNode.labelMarginTop = 0; + + // Update compound bounds considering its label properties and set label margins for left and top + if (CoSEConstants.NODE_DIMENSIONS_INCLUDE_LABELS) { + + var width = compoundNode.rect.width; + var height = compoundNode.rect.height; + + if (compoundNode.labelWidth) { + if (compoundNode.labelPosHorizontal == "left") { + compoundNode.rect.x -= compoundNode.labelWidth; + compoundNode.setWidth(width + compoundNode.labelWidth); + compoundNode.labelMarginLeft = compoundNode.labelWidth; + } else if (compoundNode.labelPosHorizontal == "center" && compoundNode.labelWidth > width) { + compoundNode.rect.x -= (compoundNode.labelWidth - width) / 2; + compoundNode.setWidth(compoundNode.labelWidth); + compoundNode.labelMarginLeft = (compoundNode.labelWidth - width) / 2; + } else if (compoundNode.labelPosHorizontal == "right") { + compoundNode.setWidth(width + compoundNode.labelWidth); + } + } + + if (compoundNode.labelHeight) { + if (compoundNode.labelPosVertical == "top") { + compoundNode.rect.y -= compoundNode.labelHeight; + compoundNode.setHeight(height + compoundNode.labelHeight); + compoundNode.labelMarginTop = compoundNode.labelHeight; + } else if (compoundNode.labelPosVertical == "center" && compoundNode.labelHeight > height) { + compoundNode.rect.y -= (compoundNode.labelHeight - height) / 2; + compoundNode.setHeight(compoundNode.labelHeight); + compoundNode.labelMarginTop = (compoundNode.labelHeight - height) / 2; + } else if (compoundNode.labelPosVertical == "bottom") { + compoundNode.setHeight(height + compoundNode.labelHeight); + } + } + } + }); +}; + +CoSELayout.prototype.repopulateCompounds = function () { + for (var i = this.compoundOrder.length - 1; i >= 0; i--) { + var lCompoundNode = this.compoundOrder[i]; + var id = lCompoundNode.id; + var horizontalMargin = lCompoundNode.paddingLeft; + var verticalMargin = lCompoundNode.paddingTop; + var labelMarginLeft = lCompoundNode.labelMarginLeft; + var labelMarginTop = lCompoundNode.labelMarginTop; + + this.adjustLocations(this.tiledMemberPack[id], lCompoundNode.rect.x, lCompoundNode.rect.y, horizontalMargin, verticalMargin, labelMarginLeft, labelMarginTop); + } +}; + +CoSELayout.prototype.repopulateZeroDegreeMembers = function () { + var self = this; + var tiledPack = this.tiledZeroDegreePack; + + Object.keys(tiledPack).forEach(function (id) { + var compoundNode = self.idToDummyNode[id]; // Get the dummy compound by its id + var horizontalMargin = compoundNode.paddingLeft; + var verticalMargin = compoundNode.paddingTop; + var labelMarginLeft = compoundNode.labelMarginLeft; + var labelMarginTop = compoundNode.labelMarginTop; + + // Adjust the positions of nodes wrt its compound + self.adjustLocations(tiledPack[id], compoundNode.rect.x, compoundNode.rect.y, horizontalMargin, verticalMargin, labelMarginLeft, labelMarginTop); + }); +}; + +CoSELayout.prototype.getToBeTiled = function (node) { + var id = node.id; + //firstly check the previous results + if (this.toBeTiled[id] != null) { + return this.toBeTiled[id]; + } + + //only compound nodes are to be tiled + var childGraph = node.getChild(); + if (childGraph == null) { + this.toBeTiled[id] = false; + return false; + } + + var children = childGraph.getNodes(); // Get the children nodes + + //a compound node is not to be tiled if all of its compound children are not to be tiled + for (var i = 0; i < children.length; i++) { + var theChild = children[i]; + + if (this.getNodeDegree(theChild) > 0) { + this.toBeTiled[id] = false; + return false; + } + + //pass the children not having the compound structure + if (theChild.getChild() == null) { + this.toBeTiled[theChild.id] = false; + continue; + } + + if (!this.getToBeTiled(theChild)) { + this.toBeTiled[id] = false; + return false; + } + } + this.toBeTiled[id] = true; + return true; +}; + +// Get degree of a node depending of its edges and independent of its children +CoSELayout.prototype.getNodeDegree = function (node) { + var id = node.id; + var edges = node.getEdges(); + var degree = 0; + + // For the edges connected + for (var i = 0; i < edges.length; i++) { + var edge = edges[i]; + if (edge.getSource().id !== edge.getTarget().id) { + degree = degree + 1; + } + } + return degree; +}; + +// Get degree of a node with its children +CoSELayout.prototype.getNodeDegreeWithChildren = function (node) { + var degree = this.getNodeDegree(node); + if (node.getChild() == null) { + return degree; + } + var children = node.getChild().getNodes(); + for (var i = 0; i < children.length; i++) { + var child = children[i]; + degree += this.getNodeDegreeWithChildren(child); + } + return degree; +}; + +CoSELayout.prototype.performDFSOnCompounds = function () { + this.compoundOrder = []; + this.fillCompexOrderByDFS(this.graphManager.getRoot().getNodes()); +}; + +CoSELayout.prototype.fillCompexOrderByDFS = function (children) { + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child.getChild() != null) { + this.fillCompexOrderByDFS(child.getChild().getNodes()); + } + if (this.getToBeTiled(child)) { + this.compoundOrder.push(child); + } + } +}; + +/** +* This method places each zero degree member wrt given (x,y) coordinates (top left). +*/ +CoSELayout.prototype.adjustLocations = function (organization, x, y, compoundHorizontalMargin, compoundVerticalMargin, compoundLabelMarginLeft, compoundLabelMarginTop) { + x += compoundHorizontalMargin + compoundLabelMarginLeft; + y += compoundVerticalMargin + compoundLabelMarginTop; + + var left = x; + + for (var i = 0; i < organization.rows.length; i++) { + var row = organization.rows[i]; + x = left; + var maxHeight = 0; + + for (var j = 0; j < row.length; j++) { + var lnode = row[j]; + + lnode.rect.x = x; // + lnode.rect.width / 2; + lnode.rect.y = y; // + lnode.rect.height / 2; + + x += lnode.rect.width + organization.horizontalPadding; + + if (lnode.rect.height > maxHeight) maxHeight = lnode.rect.height; + } + + y += maxHeight + organization.verticalPadding; + } +}; + +CoSELayout.prototype.tileCompoundMembers = function (childGraphMap, idToNode) { + var self = this; + this.tiledMemberPack = []; + + Object.keys(childGraphMap).forEach(function (id) { + // Get the compound node + var compoundNode = idToNode[id]; + + self.tiledMemberPack[id] = self.tileNodes(childGraphMap[id], compoundNode.paddingLeft + compoundNode.paddingRight); + + compoundNode.rect.width = self.tiledMemberPack[id].width; + compoundNode.rect.height = self.tiledMemberPack[id].height; + compoundNode.setCenter(self.tiledMemberPack[id].centerX, self.tiledMemberPack[id].centerY); + + // compound left and top margings for labels + // when node labels are included, these values may be set to different values below and are used in tilingPostLayout, + // otherwise they stay as zero + compoundNode.labelMarginLeft = 0; + compoundNode.labelMarginTop = 0; + + // Update compound bounds considering its label properties and set label margins for left and top + if (CoSEConstants.NODE_DIMENSIONS_INCLUDE_LABELS) { + + var width = compoundNode.rect.width; + var height = compoundNode.rect.height; + + if (compoundNode.labelWidth) { + if (compoundNode.labelPosHorizontal == "left") { + compoundNode.rect.x -= compoundNode.labelWidth; + compoundNode.setWidth(width + compoundNode.labelWidth); + compoundNode.labelMarginLeft = compoundNode.labelWidth; + } else if (compoundNode.labelPosHorizontal == "center" && compoundNode.labelWidth > width) { + compoundNode.rect.x -= (compoundNode.labelWidth - width) / 2; + compoundNode.setWidth(compoundNode.labelWidth); + compoundNode.labelMarginLeft = (compoundNode.labelWidth - width) / 2; + } else if (compoundNode.labelPosHorizontal == "right") { + compoundNode.setWidth(width + compoundNode.labelWidth); + } + } + + if (compoundNode.labelHeight) { + if (compoundNode.labelPosVertical == "top") { + compoundNode.rect.y -= compoundNode.labelHeight; + compoundNode.setHeight(height + compoundNode.labelHeight); + compoundNode.labelMarginTop = compoundNode.labelHeight; + } else if (compoundNode.labelPosVertical == "center" && compoundNode.labelHeight > height) { + compoundNode.rect.y -= (compoundNode.labelHeight - height) / 2; + compoundNode.setHeight(compoundNode.labelHeight); + compoundNode.labelMarginTop = (compoundNode.labelHeight - height) / 2; + } else if (compoundNode.labelPosVertical == "bottom") { + compoundNode.setHeight(height + compoundNode.labelHeight); + } + } + } + }); +}; + +CoSELayout.prototype.tileNodes = function (nodes, minWidth) { + var horizontalOrg = this.tileNodesByFavoringDim(nodes, minWidth, true); + var verticalOrg = this.tileNodesByFavoringDim(nodes, minWidth, false); + + var horizontalRatio = this.getOrgRatio(horizontalOrg); + var verticalRatio = this.getOrgRatio(verticalOrg); + var bestOrg; + + // the best ratio is the one that is closer to 1 since the ratios are already normalized + // and the best organization is the one that has the best ratio + if (verticalRatio < horizontalRatio) { + bestOrg = verticalOrg; + } else { + bestOrg = horizontalOrg; + } + + return bestOrg; +}; + +// get the width/height ratio of the organization that is normalized so that it will not be less than 1 +CoSELayout.prototype.getOrgRatio = function (organization) { + // get dimensions and calculate the initial ratio + var width = organization.width; + var height = organization.height; + var ratio = width / height; + + // if the initial ratio is less then 1 then inverse it + if (ratio < 1) { + ratio = 1 / ratio; + } + + // return the normalized ratio + return ratio; +}; + +/* + * Calculates the ideal width for the rows. This method assumes that + * each node has the same sizes and calculates the ideal row width that + * approximates a square shaped complex accordingly. However, since nodes would + * have different sizes some rows would have different sizes and the resulting + * shape would not be an exact square. + */ +CoSELayout.prototype.calcIdealRowWidth = function (members, favorHorizontalDim) { + // To approximate a square shaped complex we need to make complex width equal to complex height. + // To achieve this we need to solve the following equation system for hc: + // (x + bx) * hc - bx = (y + by) * vc - by, hc * vc = n + // where x is the avarage width of the nodes, y is the avarage height of nodes + // bx and by are the buffer sizes in horizontal and vertical dimensions accordingly, + // hc and vc are the number of rows in horizontal and vertical dimensions + // n is number of members. + + var verticalPadding = CoSEConstants.TILING_PADDING_VERTICAL; + var horizontalPadding = CoSEConstants.TILING_PADDING_HORIZONTAL; + + // number of members + var membersSize = members.length; + + // sum of the width of all members + var totalWidth = 0; + + // sum of the height of all members + var totalHeight = 0; + + var maxWidth = 0; + + // traverse all members to calculate total width and total height and get the maximum members width + members.forEach(function (node) { + totalWidth += node.getWidth(); + totalHeight += node.getHeight(); + + if (node.getWidth() > maxWidth) { + maxWidth = node.getWidth(); + } + }); + + // average width of the members + var averageWidth = totalWidth / membersSize; + + // average height of the members + var averageHeight = totalHeight / membersSize; + + // solving the initial equation system for the hc yields the following second degree equation: + // hc^2 * (x+bx) + hc * (by - bx) - n * (y + by) = 0 + + // the delta value to solve the equation above for hc + var delta = Math.pow(verticalPadding - horizontalPadding, 2) + 4 * (averageWidth + horizontalPadding) * (averageHeight + verticalPadding) * membersSize; + + // solve the equation using delta value to calculate the horizontal count + // that represents the number of nodes in an ideal row + var horizontalCountDouble = (horizontalPadding - verticalPadding + Math.sqrt(delta)) / (2 * (averageWidth + horizontalPadding)); + // round the calculated horizontal count up or down according to the favored dimension + var horizontalCount; + + if (favorHorizontalDim) { + horizontalCount = Math.ceil(horizontalCountDouble); + // if horizontalCount count is not a float value then both of rounding to floor and ceil + // will yield the same values. Instead of repeating the same calculation try going up + // while favoring horizontal dimension in such cases + if (horizontalCount == horizontalCountDouble) { + horizontalCount++; + } + } else { + horizontalCount = Math.floor(horizontalCountDouble); + } + + // ideal width to be calculated + var idealWidth = horizontalCount * (averageWidth + horizontalPadding) - horizontalPadding; + + // if max width is bigger than calculated ideal width reset ideal width to it + if (maxWidth > idealWidth) { + idealWidth = maxWidth; + } + + // add the left-right margins to the ideal row width + idealWidth += horizontalPadding * 2; + + // return the ideal row width1 + return idealWidth; +}; + +CoSELayout.prototype.tileNodesByFavoringDim = function (nodes, minWidth, favorHorizontalDim) { + var verticalPadding = CoSEConstants.TILING_PADDING_VERTICAL; + var horizontalPadding = CoSEConstants.TILING_PADDING_HORIZONTAL; + var tilingCompareBy = CoSEConstants.TILING_COMPARE_BY; + var organization = { + rows: [], + rowWidth: [], + rowHeight: [], + width: 0, + height: minWidth, // assume minHeight equals to minWidth + verticalPadding: verticalPadding, + horizontalPadding: horizontalPadding, + centerX: 0, + centerY: 0 + }; + + if (tilingCompareBy) { + organization.idealRowWidth = this.calcIdealRowWidth(nodes, favorHorizontalDim); + } + + var getNodeArea = function getNodeArea(n) { + return n.rect.width * n.rect.height; + }; + + var areaCompareFcn = function areaCompareFcn(n1, n2) { + return getNodeArea(n2) - getNodeArea(n1); + }; + + // Sort the nodes in descending order of their areas + nodes.sort(function (n1, n2) { + var cmpBy = areaCompareFcn; + if (organization.idealRowWidth) { + cmpBy = tilingCompareBy; + return cmpBy(n1.id, n2.id); + } + return cmpBy(n1, n2); + }); + + // Create the organization -> calculate compound center + var sumCenterX = 0; + var sumCenterY = 0; + for (var i = 0; i < nodes.length; i++) { + var lNode = nodes[i]; + + sumCenterX += lNode.getCenterX(); + sumCenterY += lNode.getCenterY(); + } + + organization.centerX = sumCenterX / nodes.length; + organization.centerY = sumCenterY / nodes.length; + + // Create the organization -> tile members + for (var i = 0; i < nodes.length; i++) { + var lNode = nodes[i]; + + if (organization.rows.length == 0) { + this.insertNodeToRow(organization, lNode, 0, minWidth); + } else if (this.canAddHorizontal(organization, lNode.rect.width, lNode.rect.height)) { + var rowIndex = organization.rows.length - 1; + if (!organization.idealRowWidth) { + rowIndex = this.getShortestRowIndex(organization); + } + this.insertNodeToRow(organization, lNode, rowIndex, minWidth); + } else { + this.insertNodeToRow(organization, lNode, organization.rows.length, minWidth); + } + + this.shiftToLastRow(organization); + } + + return organization; +}; + +CoSELayout.prototype.insertNodeToRow = function (organization, node, rowIndex, minWidth) { + var minCompoundSize = minWidth; + + // Add new row if needed + if (rowIndex == organization.rows.length) { + var secondDimension = []; + + organization.rows.push(secondDimension); + organization.rowWidth.push(minCompoundSize); + organization.rowHeight.push(0); + } + + // Update row width + var w = organization.rowWidth[rowIndex] + node.rect.width; + + if (organization.rows[rowIndex].length > 0) { + w += organization.horizontalPadding; + } + + organization.rowWidth[rowIndex] = w; + // Update compound width + if (organization.width < w) { + organization.width = w; + } + + // Update height + var h = node.rect.height; + if (rowIndex > 0) h += organization.verticalPadding; + + var extraHeight = 0; + if (h > organization.rowHeight[rowIndex]) { + extraHeight = organization.rowHeight[rowIndex]; + organization.rowHeight[rowIndex] = h; + extraHeight = organization.rowHeight[rowIndex] - extraHeight; + } + + organization.height += extraHeight; + + // Insert node + organization.rows[rowIndex].push(node); +}; + +//Scans the rows of an organization and returns the one with the min width +CoSELayout.prototype.getShortestRowIndex = function (organization) { + var r = -1; + var min = Number.MAX_VALUE; + + for (var i = 0; i < organization.rows.length; i++) { + if (organization.rowWidth[i] < min) { + r = i; + min = organization.rowWidth[i]; + } + } + return r; +}; + +//Scans the rows of an organization and returns the one with the max width +CoSELayout.prototype.getLongestRowIndex = function (organization) { + var r = -1; + var max = Number.MIN_VALUE; + + for (var i = 0; i < organization.rows.length; i++) { + + if (organization.rowWidth[i] > max) { + r = i; + max = organization.rowWidth[i]; + } + } + + return r; +}; + +/** +* This method checks whether adding extra width to the organization violates +* the aspect ratio(1) or not. +*/ +CoSELayout.prototype.canAddHorizontal = function (organization, extraWidth, extraHeight) { + + // if there is an ideal row width specified use it instead of checking the aspect ratio + if (organization.idealRowWidth) { + var lastRowIndex = organization.rows.length - 1; + var lastRowWidth = organization.rowWidth[lastRowIndex]; + + // check and return if ideal row width will be exceed if the node is added to the row + return lastRowWidth + extraWidth + organization.horizontalPadding <= organization.idealRowWidth; + } + + var sri = this.getShortestRowIndex(organization); + + if (sri < 0) { + return true; + } + + var min = organization.rowWidth[sri]; + + if (min + organization.horizontalPadding + extraWidth <= organization.width) return true; + + var hDiff = 0; + + // Adding to an existing row + if (organization.rowHeight[sri] < extraHeight) { + if (sri > 0) hDiff = extraHeight + organization.verticalPadding - organization.rowHeight[sri]; + } + + var add_to_row_ratio; + if (organization.width - min >= extraWidth + organization.horizontalPadding) { + add_to_row_ratio = (organization.height + hDiff) / (min + extraWidth + organization.horizontalPadding); + } else { + add_to_row_ratio = (organization.height + hDiff) / organization.width; + } + + // Adding a new row for this node + hDiff = extraHeight + organization.verticalPadding; + var add_new_row_ratio; + if (organization.width < extraWidth) { + add_new_row_ratio = (organization.height + hDiff) / extraWidth; + } else { + add_new_row_ratio = (organization.height + hDiff) / organization.width; + } + + if (add_new_row_ratio < 1) add_new_row_ratio = 1 / add_new_row_ratio; + + if (add_to_row_ratio < 1) add_to_row_ratio = 1 / add_to_row_ratio; + + return add_to_row_ratio < add_new_row_ratio; +}; + +//If moving the last node from the longest row and adding it to the last +//row makes the bounding box smaller, do it. +CoSELayout.prototype.shiftToLastRow = function (organization) { + var longest = this.getLongestRowIndex(organization); + var last = organization.rowWidth.length - 1; + var row = organization.rows[longest]; + var node = row[row.length - 1]; + + var diff = node.width + organization.horizontalPadding; + + // Check if there is enough space on the last row + if (organization.width - organization.rowWidth[last] > diff && longest != last) { + // Remove the last element of the longest row + row.splice(-1, 1); + + // Push it to the last row + organization.rows[last].push(node); + + organization.rowWidth[longest] = organization.rowWidth[longest] - diff; + organization.rowWidth[last] = organization.rowWidth[last] + diff; + organization.width = organization.rowWidth[instance.getLongestRowIndex(organization)]; + + // Update heights of the organization + var maxHeight = Number.MIN_VALUE; + for (var i = 0; i < row.length; i++) { + if (row[i].height > maxHeight) maxHeight = row[i].height; + } + if (longest > 0) maxHeight += organization.verticalPadding; + + var prevTotal = organization.rowHeight[longest] + organization.rowHeight[last]; + + organization.rowHeight[longest] = maxHeight; + if (organization.rowHeight[last] < node.height + organization.verticalPadding) organization.rowHeight[last] = node.height + organization.verticalPadding; + + var finalTotal = organization.rowHeight[longest] + organization.rowHeight[last]; + organization.height += finalTotal - prevTotal; + + this.shiftToLastRow(organization); + } +}; + +CoSELayout.prototype.tilingPreLayout = function () { + if (CoSEConstants.TILE) { + // Find zero degree nodes and create a compound for each level + this.groupZeroDegreeMembers(); + // Tile and clear children of each compound + this.clearCompounds(); + // Separately tile and clear zero degree nodes for each level + this.clearZeroDegreeMembers(); + } +}; + +CoSELayout.prototype.tilingPostLayout = function () { + if (CoSEConstants.TILE) { + this.repopulateZeroDegreeMembers(); + this.repopulateCompounds(); + } +}; + +// ----------------------------------------------------------------------------- +// Section: Tree Reduction methods +// ----------------------------------------------------------------------------- +// Reduce trees +CoSELayout.prototype.reduceTrees = function () { + var prunedNodesAll = []; + var containsLeaf = true; + var node; + + while (containsLeaf) { + var allNodes = this.graphManager.getAllNodes(); + var prunedNodesInStepTemp = []; + containsLeaf = false; + + for (var i = 0; i < allNodes.length; i++) { + node = allNodes[i]; + if (node.getEdges().length == 1 && !node.getEdges()[0].isInterGraph && node.getChild() == null) { + if (CoSEConstants.PURE_INCREMENTAL) { + var otherEnd = node.getEdges()[0].getOtherEnd(node); + var relativePosition = new DimensionD(node.getCenterX() - otherEnd.getCenterX(), node.getCenterY() - otherEnd.getCenterY()); + prunedNodesInStepTemp.push([node, node.getEdges()[0], node.getOwner(), relativePosition]); + } else { + prunedNodesInStepTemp.push([node, node.getEdges()[0], node.getOwner()]); + } + containsLeaf = true; + } + } + if (containsLeaf == true) { + var prunedNodesInStep = []; + for (var j = 0; j < prunedNodesInStepTemp.length; j++) { + if (prunedNodesInStepTemp[j][0].getEdges().length == 1) { + prunedNodesInStep.push(prunedNodesInStepTemp[j]); + prunedNodesInStepTemp[j][0].getOwner().remove(prunedNodesInStepTemp[j][0]); + } + } + prunedNodesAll.push(prunedNodesInStep); + this.graphManager.resetAllNodes(); + this.graphManager.resetAllEdges(); + } + } + this.prunedNodesAll = prunedNodesAll; +}; + +// Grow tree one step +CoSELayout.prototype.growTree = function (prunedNodesAll) { + var lengthOfPrunedNodesInStep = prunedNodesAll.length; + var prunedNodesInStep = prunedNodesAll[lengthOfPrunedNodesInStep - 1]; + + var nodeData; + for (var i = 0; i < prunedNodesInStep.length; i++) { + nodeData = prunedNodesInStep[i]; + + this.findPlaceforPrunedNode(nodeData); + + nodeData[2].add(nodeData[0]); + nodeData[2].add(nodeData[1], nodeData[1].source, nodeData[1].target); + } + + prunedNodesAll.splice(prunedNodesAll.length - 1, 1); + this.graphManager.resetAllNodes(); + this.graphManager.resetAllEdges(); +}; + +// Find an appropriate position to replace pruned node, this method can be improved +CoSELayout.prototype.findPlaceforPrunedNode = function (nodeData) { + + var gridForPrunedNode; + var nodeToConnect; + var prunedNode = nodeData[0]; + if (prunedNode == nodeData[1].source) { + nodeToConnect = nodeData[1].target; + } else { + nodeToConnect = nodeData[1].source; + } + + if (CoSEConstants.PURE_INCREMENTAL) { + prunedNode.setCenter(nodeToConnect.getCenterX() + nodeData[3].getWidth(), nodeToConnect.getCenterY() + nodeData[3].getHeight()); + } else { + var startGridX = nodeToConnect.startX; + var finishGridX = nodeToConnect.finishX; + var startGridY = nodeToConnect.startY; + var finishGridY = nodeToConnect.finishY; + + var upNodeCount = 0; + var downNodeCount = 0; + var rightNodeCount = 0; + var leftNodeCount = 0; + var controlRegions = [upNodeCount, rightNodeCount, downNodeCount, leftNodeCount]; + + if (startGridY > 0) { + for (var i = startGridX; i <= finishGridX; i++) { + controlRegions[0] += this.grid[i][startGridY - 1].length + this.grid[i][startGridY].length - 1; + } + } + if (finishGridX < this.grid.length - 1) { + for (var i = startGridY; i <= finishGridY; i++) { + controlRegions[1] += this.grid[finishGridX + 1][i].length + this.grid[finishGridX][i].length - 1; + } + } + if (finishGridY < this.grid[0].length - 1) { + for (var i = startGridX; i <= finishGridX; i++) { + controlRegions[2] += this.grid[i][finishGridY + 1].length + this.grid[i][finishGridY].length - 1; + } + } + if (startGridX > 0) { + for (var i = startGridY; i <= finishGridY; i++) { + controlRegions[3] += this.grid[startGridX - 1][i].length + this.grid[startGridX][i].length - 1; + } + } + var min = Integer.MAX_VALUE; + var minCount; + var minIndex; + for (var j = 0; j < controlRegions.length; j++) { + if (controlRegions[j] < min) { + min = controlRegions[j]; + minCount = 1; + minIndex = j; + } else if (controlRegions[j] == min) { + minCount++; + } + } + + if (minCount == 3 && min == 0) { + if (controlRegions[0] == 0 && controlRegions[1] == 0 && controlRegions[2] == 0) { + gridForPrunedNode = 1; + } else if (controlRegions[0] == 0 && controlRegions[1] == 0 && controlRegions[3] == 0) { + gridForPrunedNode = 0; + } else if (controlRegions[0] == 0 && controlRegions[2] == 0 && controlRegions[3] == 0) { + gridForPrunedNode = 3; + } else if (controlRegions[1] == 0 && controlRegions[2] == 0 && controlRegions[3] == 0) { + gridForPrunedNode = 2; + } + } else if (minCount == 2 && min == 0) { + var random = Math.floor(Math.random() * 2); + if (controlRegions[0] == 0 && controlRegions[1] == 0) { + ; + if (random == 0) { + gridForPrunedNode = 0; + } else { + gridForPrunedNode = 1; + } + } else if (controlRegions[0] == 0 && controlRegions[2] == 0) { + if (random == 0) { + gridForPrunedNode = 0; + } else { + gridForPrunedNode = 2; + } + } else if (controlRegions[0] == 0 && controlRegions[3] == 0) { + if (random == 0) { + gridForPrunedNode = 0; + } else { + gridForPrunedNode = 3; + } + } else if (controlRegions[1] == 0 && controlRegions[2] == 0) { + if (random == 0) { + gridForPrunedNode = 1; + } else { + gridForPrunedNode = 2; + } + } else if (controlRegions[1] == 0 && controlRegions[3] == 0) { + if (random == 0) { + gridForPrunedNode = 1; + } else { + gridForPrunedNode = 3; + } + } else { + if (random == 0) { + gridForPrunedNode = 2; + } else { + gridForPrunedNode = 3; + } + } + } else if (minCount == 4 && min == 0) { + var random = Math.floor(Math.random() * 4); + gridForPrunedNode = random; + } else { + gridForPrunedNode = minIndex; + } + + if (gridForPrunedNode == 0) { + prunedNode.setCenter(nodeToConnect.getCenterX(), nodeToConnect.getCenterY() - nodeToConnect.getHeight() / 2 - FDLayoutConstants.DEFAULT_EDGE_LENGTH - prunedNode.getHeight() / 2); + } else if (gridForPrunedNode == 1) { + prunedNode.setCenter(nodeToConnect.getCenterX() + nodeToConnect.getWidth() / 2 + FDLayoutConstants.DEFAULT_EDGE_LENGTH + prunedNode.getWidth() / 2, nodeToConnect.getCenterY()); + } else if (gridForPrunedNode == 2) { + prunedNode.setCenter(nodeToConnect.getCenterX(), nodeToConnect.getCenterY() + nodeToConnect.getHeight() / 2 + FDLayoutConstants.DEFAULT_EDGE_LENGTH + prunedNode.getHeight() / 2); + } else { + prunedNode.setCenter(nodeToConnect.getCenterX() - nodeToConnect.getWidth() / 2 - FDLayoutConstants.DEFAULT_EDGE_LENGTH - prunedNode.getWidth() / 2, nodeToConnect.getCenterY()); + } + } +}; + +module.exports = CoSELayout; + +/***/ }), + +/***/ 991: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +var FDLayoutNode = __webpack_require__(551).FDLayoutNode; +var IMath = __webpack_require__(551).IMath; + +function CoSENode(gm, loc, size, vNode) { + FDLayoutNode.call(this, gm, loc, size, vNode); +} + +CoSENode.prototype = Object.create(FDLayoutNode.prototype); +for (var prop in FDLayoutNode) { + CoSENode[prop] = FDLayoutNode[prop]; +} + +CoSENode.prototype.calculateDisplacement = function () { + var layout = this.graphManager.getLayout(); + // this check is for compound nodes that contain fixed nodes + if (this.getChild() != null && this.fixedNodeWeight) { + this.displacementX += layout.coolingFactor * (this.springForceX + this.repulsionForceX + this.gravitationForceX) / this.fixedNodeWeight; + this.displacementY += layout.coolingFactor * (this.springForceY + this.repulsionForceY + this.gravitationForceY) / this.fixedNodeWeight; + } else { + this.displacementX += layout.coolingFactor * (this.springForceX + this.repulsionForceX + this.gravitationForceX) / this.noOfChildren; + this.displacementY += layout.coolingFactor * (this.springForceY + this.repulsionForceY + this.gravitationForceY) / this.noOfChildren; + } + + if (Math.abs(this.displacementX) > layout.coolingFactor * layout.maxNodeDisplacement) { + this.displacementX = layout.coolingFactor * layout.maxNodeDisplacement * IMath.sign(this.displacementX); + } + + if (Math.abs(this.displacementY) > layout.coolingFactor * layout.maxNodeDisplacement) { + this.displacementY = layout.coolingFactor * layout.maxNodeDisplacement * IMath.sign(this.displacementY); + } + + // non-empty compound node, propogate movement to children as well + if (this.child && this.child.getNodes().length > 0) { + this.propogateDisplacementToChildren(this.displacementX, this.displacementY); + } +}; + +CoSENode.prototype.propogateDisplacementToChildren = function (dX, dY) { + var nodes = this.getChild().getNodes(); + var node; + for (var i = 0; i < nodes.length; i++) { + node = nodes[i]; + if (node.getChild() == null) { + node.displacementX += dX; + node.displacementY += dY; + } else { + node.propogateDisplacementToChildren(dX, dY); + } + } +}; + +CoSENode.prototype.move = function () { + var layout = this.graphManager.getLayout(); + + // a simple node or an empty compound node, move it + if (this.child == null || this.child.getNodes().length == 0) { + this.moveBy(this.displacementX, this.displacementY); + + layout.totalDisplacement += Math.abs(this.displacementX) + Math.abs(this.displacementY); + } + + this.springForceX = 0; + this.springForceY = 0; + this.repulsionForceX = 0; + this.repulsionForceY = 0; + this.gravitationForceX = 0; + this.gravitationForceY = 0; + this.displacementX = 0; + this.displacementY = 0; +}; + +CoSENode.prototype.setPred1 = function (pred1) { + this.pred1 = pred1; +}; + +CoSENode.prototype.getPred1 = function () { + return pred1; +}; + +CoSENode.prototype.getPred2 = function () { + return pred2; +}; + +CoSENode.prototype.setNext = function (next) { + this.next = next; +}; + +CoSENode.prototype.getNext = function () { + return next; +}; + +CoSENode.prototype.setProcessed = function (processed) { + this.processed = processed; +}; + +CoSENode.prototype.isProcessed = function () { + return processed; +}; + +module.exports = CoSENode; + +/***/ }), + +/***/ 902: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } + +var CoSEConstants = __webpack_require__(806); +var LinkedList = __webpack_require__(551).LinkedList; +var Matrix = __webpack_require__(551).Matrix; +var SVD = __webpack_require__(551).SVD; + +function ConstraintHandler() {} + +ConstraintHandler.handleConstraints = function (layout) { + // let layout = this.graphManager.getLayout(); + + // get constraints from layout + var constraints = {}; + constraints.fixedNodeConstraint = layout.constraints.fixedNodeConstraint; + constraints.alignmentConstraint = layout.constraints.alignmentConstraint; + constraints.relativePlacementConstraint = layout.constraints.relativePlacementConstraint; + + var idToNodeMap = new Map(); + var nodeIndexes = new Map(); + var xCoords = []; + var yCoords = []; + + var allNodes = layout.getAllNodes(); + var index = 0; + // fill index map and coordinates + for (var i = 0; i < allNodes.length; i++) { + var node = allNodes[i]; + if (node.getChild() == null) { + nodeIndexes.set(node.id, index++); + xCoords.push(node.getCenterX()); + yCoords.push(node.getCenterY()); + idToNodeMap.set(node.id, node); + } + } + + // if there exists relative placement constraint without gap value, set it to default + if (constraints.relativePlacementConstraint) { + constraints.relativePlacementConstraint.forEach(function (constraint) { + if (!constraint.gap && constraint.gap != 0) { + if (constraint.left) { + constraint.gap = CoSEConstants.DEFAULT_EDGE_LENGTH + idToNodeMap.get(constraint.left).getWidth() / 2 + idToNodeMap.get(constraint.right).getWidth() / 2; + } else { + constraint.gap = CoSEConstants.DEFAULT_EDGE_LENGTH + idToNodeMap.get(constraint.top).getHeight() / 2 + idToNodeMap.get(constraint.bottom).getHeight() / 2; + } + } + }); + } + + /* auxiliary functions */ + + // calculate difference between two position objects + var calculatePositionDiff = function calculatePositionDiff(pos1, pos2) { + return { x: pos1.x - pos2.x, y: pos1.y - pos2.y }; + }; + + // calculate average position of the nodes + var calculateAvgPosition = function calculateAvgPosition(nodeIdSet) { + var xPosSum = 0; + var yPosSum = 0; + nodeIdSet.forEach(function (nodeId) { + xPosSum += xCoords[nodeIndexes.get(nodeId)]; + yPosSum += yCoords[nodeIndexes.get(nodeId)]; + }); + + return { x: xPosSum / nodeIdSet.size, y: yPosSum / nodeIdSet.size }; + }; + + // find an appropriate positioning for the nodes in a given graph according to relative placement constraints + // this function also takes the fixed nodes and alignment constraints into account + // graph: dag to be evaluated, direction: "horizontal" or "vertical", + // fixedNodes: set of fixed nodes to consider during evaluation, dummyPositions: appropriate coordinates of the dummy nodes + var findAppropriatePositionForRelativePlacement = function findAppropriatePositionForRelativePlacement(graph, direction, fixedNodes, dummyPositions, componentSources) { + + // find union of two sets + function setUnion(setA, setB) { + var union = new Set(setA); + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + for (var _iterator = setB[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var elem = _step.value; + + union.add(elem); + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + return union; + } + + // find indegree count for each node + var inDegrees = new Map(); + + graph.forEach(function (value, key) { + inDegrees.set(key, 0); + }); + graph.forEach(function (value, key) { + value.forEach(function (adjacent) { + inDegrees.set(adjacent.id, inDegrees.get(adjacent.id) + 1); + }); + }); + + var positionMap = new Map(); // keeps the position for each node + var pastMap = new Map(); // keeps the predecessors(past) of a node + var queue = new LinkedList(); + inDegrees.forEach(function (value, key) { + if (value == 0) { + queue.push(key); + if (!fixedNodes) { + if (direction == "horizontal") { + positionMap.set(key, nodeIndexes.has(key) ? xCoords[nodeIndexes.get(key)] : dummyPositions.get(key)); + } else { + positionMap.set(key, nodeIndexes.has(key) ? yCoords[nodeIndexes.get(key)] : dummyPositions.get(key)); + } + } + } else { + positionMap.set(key, Number.NEGATIVE_INFINITY); + } + if (fixedNodes) { + pastMap.set(key, new Set([key])); + } + }); + + // align sources of each component in enforcement phase + if (fixedNodes) { + componentSources.forEach(function (component) { + var fixedIds = []; + component.forEach(function (nodeId) { + if (fixedNodes.has(nodeId)) { + fixedIds.push(nodeId); + } + }); + if (fixedIds.length > 0) { + var position = 0; + fixedIds.forEach(function (fixedId) { + if (direction == "horizontal") { + positionMap.set(fixedId, nodeIndexes.has(fixedId) ? xCoords[nodeIndexes.get(fixedId)] : dummyPositions.get(fixedId)); + position += positionMap.get(fixedId); + } else { + positionMap.set(fixedId, nodeIndexes.has(fixedId) ? yCoords[nodeIndexes.get(fixedId)] : dummyPositions.get(fixedId)); + position += positionMap.get(fixedId); + } + }); + position = position / fixedIds.length; + component.forEach(function (nodeId) { + if (!fixedNodes.has(nodeId)) { + positionMap.set(nodeId, position); + } + }); + } else { + var _position = 0; + component.forEach(function (nodeId) { + if (direction == "horizontal") { + _position += nodeIndexes.has(nodeId) ? xCoords[nodeIndexes.get(nodeId)] : dummyPositions.get(nodeId); + } else { + _position += nodeIndexes.has(nodeId) ? yCoords[nodeIndexes.get(nodeId)] : dummyPositions.get(nodeId); + } + }); + _position = _position / component.length; + component.forEach(function (nodeId) { + positionMap.set(nodeId, _position); + }); + } + }); + } + + // calculate positions of the nodes + + var _loop = function _loop() { + var currentNode = queue.shift(); + var neighbors = graph.get(currentNode); + neighbors.forEach(function (neighbor) { + if (positionMap.get(neighbor.id) < positionMap.get(currentNode) + neighbor.gap) { + if (fixedNodes && fixedNodes.has(neighbor.id)) { + var fixedPosition = void 0; + if (direction == "horizontal") { + fixedPosition = nodeIndexes.has(neighbor.id) ? xCoords[nodeIndexes.get(neighbor.id)] : dummyPositions.get(neighbor.id); + } else { + fixedPosition = nodeIndexes.has(neighbor.id) ? yCoords[nodeIndexes.get(neighbor.id)] : dummyPositions.get(neighbor.id); + } + positionMap.set(neighbor.id, fixedPosition); // TODO: may do unnecessary work + if (fixedPosition < positionMap.get(currentNode) + neighbor.gap) { + var diff = positionMap.get(currentNode) + neighbor.gap - fixedPosition; + pastMap.get(currentNode).forEach(function (nodeId) { + positionMap.set(nodeId, positionMap.get(nodeId) - diff); + }); + } + } else { + positionMap.set(neighbor.id, positionMap.get(currentNode) + neighbor.gap); + } + } + inDegrees.set(neighbor.id, inDegrees.get(neighbor.id) - 1); + if (inDegrees.get(neighbor.id) == 0) { + queue.push(neighbor.id); + } + if (fixedNodes) { + pastMap.set(neighbor.id, setUnion(pastMap.get(currentNode), pastMap.get(neighbor.id))); + } + }); + }; + + while (queue.length != 0) { + _loop(); + } + + // readjust position of the nodes after enforcement + if (fixedNodes) { + // find indegree count for each node + var sinkNodes = new Set(); + + graph.forEach(function (value, key) { + if (value.length == 0) { + sinkNodes.add(key); + } + }); + + var _components = []; + pastMap.forEach(function (value, key) { + if (sinkNodes.has(key)) { + var isFixedComponent = false; + var _iteratorNormalCompletion2 = true; + var _didIteratorError2 = false; + var _iteratorError2 = undefined; + + try { + for (var _iterator2 = value[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { + var nodeId = _step2.value; + + if (fixedNodes.has(nodeId)) { + isFixedComponent = true; + } + } + } catch (err) { + _didIteratorError2 = true; + _iteratorError2 = err; + } finally { + try { + if (!_iteratorNormalCompletion2 && _iterator2.return) { + _iterator2.return(); + } + } finally { + if (_didIteratorError2) { + throw _iteratorError2; + } + } + } + + if (!isFixedComponent) { + var isExist = false; + var existAt = void 0; + _components.forEach(function (component, index) { + if (component.has([].concat(_toConsumableArray(value))[0])) { + isExist = true; + existAt = index; + } + }); + if (!isExist) { + _components.push(new Set(value)); + } else { + value.forEach(function (ele) { + _components[existAt].add(ele); + }); + } + } + } + }); + + _components.forEach(function (component, index) { + var minBefore = Number.POSITIVE_INFINITY; + var minAfter = Number.POSITIVE_INFINITY; + var maxBefore = Number.NEGATIVE_INFINITY; + var maxAfter = Number.NEGATIVE_INFINITY; + + var _iteratorNormalCompletion3 = true; + var _didIteratorError3 = false; + var _iteratorError3 = undefined; + + try { + for (var _iterator3 = component[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) { + var nodeId = _step3.value; + + var posBefore = void 0; + if (direction == "horizontal") { + posBefore = nodeIndexes.has(nodeId) ? xCoords[nodeIndexes.get(nodeId)] : dummyPositions.get(nodeId); + } else { + posBefore = nodeIndexes.has(nodeId) ? yCoords[nodeIndexes.get(nodeId)] : dummyPositions.get(nodeId); + } + var posAfter = positionMap.get(nodeId); + if (posBefore < minBefore) { + minBefore = posBefore; + } + if (posBefore > maxBefore) { + maxBefore = posBefore; + } + if (posAfter < minAfter) { + minAfter = posAfter; + } + if (posAfter > maxAfter) { + maxAfter = posAfter; + } + } + } catch (err) { + _didIteratorError3 = true; + _iteratorError3 = err; + } finally { + try { + if (!_iteratorNormalCompletion3 && _iterator3.return) { + _iterator3.return(); + } + } finally { + if (_didIteratorError3) { + throw _iteratorError3; + } + } + } + + var diff = (minBefore + maxBefore) / 2 - (minAfter + maxAfter) / 2; + + var _iteratorNormalCompletion4 = true; + var _didIteratorError4 = false; + var _iteratorError4 = undefined; + + try { + for (var _iterator4 = component[Symbol.iterator](), _step4; !(_iteratorNormalCompletion4 = (_step4 = _iterator4.next()).done); _iteratorNormalCompletion4 = true) { + var _nodeId = _step4.value; + + positionMap.set(_nodeId, positionMap.get(_nodeId) + diff); + } + } catch (err) { + _didIteratorError4 = true; + _iteratorError4 = err; + } finally { + try { + if (!_iteratorNormalCompletion4 && _iterator4.return) { + _iterator4.return(); + } + } finally { + if (_didIteratorError4) { + throw _iteratorError4; + } + } + } + }); + } + + return positionMap; + }; + + // find transformation based on rel. placement constraints if there are both alignment and rel. placement constraints + // or if there are only rel. placement contraints where the largest component isn't sufficiently large + var applyReflectionForRelativePlacement = function applyReflectionForRelativePlacement(relativePlacementConstraints) { + // variables to count votes + var reflectOnY = 0, + notReflectOnY = 0; + var reflectOnX = 0, + notReflectOnX = 0; + + relativePlacementConstraints.forEach(function (constraint) { + if (constraint.left) { + xCoords[nodeIndexes.get(constraint.left)] - xCoords[nodeIndexes.get(constraint.right)] >= 0 ? reflectOnY++ : notReflectOnY++; + } else { + yCoords[nodeIndexes.get(constraint.top)] - yCoords[nodeIndexes.get(constraint.bottom)] >= 0 ? reflectOnX++ : notReflectOnX++; + } + }); + + if (reflectOnY > notReflectOnY && reflectOnX > notReflectOnX) { + for (var _i = 0; _i < nodeIndexes.size; _i++) { + xCoords[_i] = -1 * xCoords[_i]; + yCoords[_i] = -1 * yCoords[_i]; + } + } else if (reflectOnY > notReflectOnY) { + for (var _i2 = 0; _i2 < nodeIndexes.size; _i2++) { + xCoords[_i2] = -1 * xCoords[_i2]; + } + } else if (reflectOnX > notReflectOnX) { + for (var _i3 = 0; _i3 < nodeIndexes.size; _i3++) { + yCoords[_i3] = -1 * yCoords[_i3]; + } + } + }; + + // find weakly connected components in undirected graph + var findComponents = function findComponents(graph) { + // find weakly connected components in dag + var components = []; + var queue = new LinkedList(); + var visited = new Set(); + var count = 0; + + graph.forEach(function (value, key) { + if (!visited.has(key)) { + components[count] = []; + var _currentNode = key; + queue.push(_currentNode); + visited.add(_currentNode); + components[count].push(_currentNode); + + while (queue.length != 0) { + _currentNode = queue.shift(); + var neighbors = graph.get(_currentNode); + neighbors.forEach(function (neighbor) { + if (!visited.has(neighbor.id)) { + queue.push(neighbor.id); + visited.add(neighbor.id); + components[count].push(neighbor.id); + } + }); + } + count++; + } + }); + return components; + }; + + // return undirected version of given dag + var dagToUndirected = function dagToUndirected(dag) { + var undirected = new Map(); + + dag.forEach(function (value, key) { + undirected.set(key, []); + }); + + dag.forEach(function (value, key) { + value.forEach(function (adjacent) { + undirected.get(key).push(adjacent); + undirected.get(adjacent.id).push({ id: key, gap: adjacent.gap, direction: adjacent.direction }); + }); + }); + + return undirected; + }; + + // return reversed (directions inverted) version of given dag + var dagToReversed = function dagToReversed(dag) { + var reversed = new Map(); + + dag.forEach(function (value, key) { + reversed.set(key, []); + }); + + dag.forEach(function (value, key) { + value.forEach(function (adjacent) { + reversed.get(adjacent.id).push({ id: key, gap: adjacent.gap, direction: adjacent.direction }); + }); + }); + + return reversed; + }; + + /**** apply transformation to the initial draft layout to better align with constrained nodes ****/ + // solve the Orthogonal Procrustean Problem to rotate and/or reflect initial draft layout + // here we follow the solution in Chapter 20.2 of Borg, I. & Groenen, P. (2005) Modern Multidimensional Scaling: Theory and Applications + + /* construct source and target configurations */ + + var targetMatrix = []; // A - target configuration + var sourceMatrix = []; // B - source configuration + var standardTransformation = false; // false for no transformation, true for standart (Procrustes) transformation (rotation and/or reflection) + var reflectionType = false; // false/true for reflection check, 'reflectOnX', 'reflectOnY' or 'reflectOnBoth' for reflection type if necessary + var fixedNodes = new Set(); + var dag = new Map(); // adjacency list to keep directed acyclic graph (dag) that consists of relative placement constraints + var dagUndirected = new Map(); // undirected version of the dag + var components = []; // weakly connected components + + // fill fixedNodes collection to use later + if (constraints.fixedNodeConstraint) { + constraints.fixedNodeConstraint.forEach(function (nodeData) { + fixedNodes.add(nodeData.nodeId); + }); + } + + // construct dag from relative placement constraints + if (constraints.relativePlacementConstraint) { + // construct both directed and undirected version of the dag + constraints.relativePlacementConstraint.forEach(function (constraint) { + if (constraint.left) { + if (dag.has(constraint.left)) { + dag.get(constraint.left).push({ id: constraint.right, gap: constraint.gap, direction: "horizontal" }); + } else { + dag.set(constraint.left, [{ id: constraint.right, gap: constraint.gap, direction: "horizontal" }]); + } + if (!dag.has(constraint.right)) { + dag.set(constraint.right, []); + } + } else { + if (dag.has(constraint.top)) { + dag.get(constraint.top).push({ id: constraint.bottom, gap: constraint.gap, direction: "vertical" }); + } else { + dag.set(constraint.top, [{ id: constraint.bottom, gap: constraint.gap, direction: "vertical" }]); + } + if (!dag.has(constraint.bottom)) { + dag.set(constraint.bottom, []); + } + } + }); + + dagUndirected = dagToUndirected(dag); + components = findComponents(dagUndirected); + } + + if (CoSEConstants.TRANSFORM_ON_CONSTRAINT_HANDLING) { + // first check fixed node constraint + if (constraints.fixedNodeConstraint && constraints.fixedNodeConstraint.length > 1) { + constraints.fixedNodeConstraint.forEach(function (nodeData, i) { + targetMatrix[i] = [nodeData.position.x, nodeData.position.y]; + sourceMatrix[i] = [xCoords[nodeIndexes.get(nodeData.nodeId)], yCoords[nodeIndexes.get(nodeData.nodeId)]]; + }); + standardTransformation = true; + } else if (constraints.alignmentConstraint) { + (function () { + // then check alignment constraint + var count = 0; + if (constraints.alignmentConstraint.vertical) { + var verticalAlign = constraints.alignmentConstraint.vertical; + + var _loop2 = function _loop2(_i4) { + var alignmentSet = new Set(); + verticalAlign[_i4].forEach(function (nodeId) { + alignmentSet.add(nodeId); + }); + var intersection = new Set([].concat(_toConsumableArray(alignmentSet)).filter(function (x) { + return fixedNodes.has(x); + })); + var xPos = void 0; + if (intersection.size > 0) xPos = xCoords[nodeIndexes.get(intersection.values().next().value)];else xPos = calculateAvgPosition(alignmentSet).x; + + verticalAlign[_i4].forEach(function (nodeId) { + targetMatrix[count] = [xPos, yCoords[nodeIndexes.get(nodeId)]]; + sourceMatrix[count] = [xCoords[nodeIndexes.get(nodeId)], yCoords[nodeIndexes.get(nodeId)]]; + count++; + }); + }; + + for (var _i4 = 0; _i4 < verticalAlign.length; _i4++) { + _loop2(_i4); + } + standardTransformation = true; + } + if (constraints.alignmentConstraint.horizontal) { + var horizontalAlign = constraints.alignmentConstraint.horizontal; + + var _loop3 = function _loop3(_i5) { + var alignmentSet = new Set(); + horizontalAlign[_i5].forEach(function (nodeId) { + alignmentSet.add(nodeId); + }); + var intersection = new Set([].concat(_toConsumableArray(alignmentSet)).filter(function (x) { + return fixedNodes.has(x); + })); + var yPos = void 0; + if (intersection.size > 0) yPos = xCoords[nodeIndexes.get(intersection.values().next().value)];else yPos = calculateAvgPosition(alignmentSet).y; + + horizontalAlign[_i5].forEach(function (nodeId) { + targetMatrix[count] = [xCoords[nodeIndexes.get(nodeId)], yPos]; + sourceMatrix[count] = [xCoords[nodeIndexes.get(nodeId)], yCoords[nodeIndexes.get(nodeId)]]; + count++; + }); + }; + + for (var _i5 = 0; _i5 < horizontalAlign.length; _i5++) { + _loop3(_i5); + } + standardTransformation = true; + } + if (constraints.relativePlacementConstraint) { + reflectionType = true; + } + })(); + } else if (constraints.relativePlacementConstraint) { + // finally check relative placement constraint + // find largest component in dag + var largestComponentSize = 0; + var largestComponentIndex = 0; + for (var _i6 = 0; _i6 < components.length; _i6++) { + if (components[_i6].length > largestComponentSize) { + largestComponentSize = components[_i6].length; + largestComponentIndex = _i6; + } + } + // if largest component isn't dominant, then take the votes for reflection + if (largestComponentSize < dagUndirected.size / 2) { + applyReflectionForRelativePlacement(constraints.relativePlacementConstraint); + standardTransformation = false; + reflectionType = false; + } else { + // use largest component for transformation + // construct horizontal and vertical subgraphs in the largest component + var subGraphOnHorizontal = new Map(); + var subGraphOnVertical = new Map(); + var constraintsInlargestComponent = []; + + components[largestComponentIndex].forEach(function (nodeId) { + dag.get(nodeId).forEach(function (adjacent) { + if (adjacent.direction == "horizontal") { + if (subGraphOnHorizontal.has(nodeId)) { + subGraphOnHorizontal.get(nodeId).push(adjacent); + } else { + subGraphOnHorizontal.set(nodeId, [adjacent]); + } + if (!subGraphOnHorizontal.has(adjacent.id)) { + subGraphOnHorizontal.set(adjacent.id, []); + } + constraintsInlargestComponent.push({ left: nodeId, right: adjacent.id }); + } else { + if (subGraphOnVertical.has(nodeId)) { + subGraphOnVertical.get(nodeId).push(adjacent); + } else { + subGraphOnVertical.set(nodeId, [adjacent]); + } + if (!subGraphOnVertical.has(adjacent.id)) { + subGraphOnVertical.set(adjacent.id, []); + } + constraintsInlargestComponent.push({ top: nodeId, bottom: adjacent.id }); + } + }); + }); + + applyReflectionForRelativePlacement(constraintsInlargestComponent); + reflectionType = false; + + // calculate appropriate positioning for subgraphs + var positionMapHorizontal = findAppropriatePositionForRelativePlacement(subGraphOnHorizontal, "horizontal"); + var positionMapVertical = findAppropriatePositionForRelativePlacement(subGraphOnVertical, "vertical"); + + // construct source and target configuration + components[largestComponentIndex].forEach(function (nodeId, i) { + sourceMatrix[i] = [xCoords[nodeIndexes.get(nodeId)], yCoords[nodeIndexes.get(nodeId)]]; + targetMatrix[i] = []; + if (positionMapHorizontal.has(nodeId)) { + targetMatrix[i][0] = positionMapHorizontal.get(nodeId); + } else { + targetMatrix[i][0] = xCoords[nodeIndexes.get(nodeId)]; + } + if (positionMapVertical.has(nodeId)) { + targetMatrix[i][1] = positionMapVertical.get(nodeId); + } else { + targetMatrix[i][1] = yCoords[nodeIndexes.get(nodeId)]; + } + }); + + standardTransformation = true; + } + } + + // if transformation is required, then calculate and apply transformation matrix + if (standardTransformation) { + /* calculate transformation matrix */ + var transformationMatrix = void 0; + var targetMatrixTranspose = Matrix.transpose(targetMatrix); // A' + var sourceMatrixTranspose = Matrix.transpose(sourceMatrix); // B' + + // centralize transpose matrices + for (var _i7 = 0; _i7 < targetMatrixTranspose.length; _i7++) { + targetMatrixTranspose[_i7] = Matrix.multGamma(targetMatrixTranspose[_i7]); + sourceMatrixTranspose[_i7] = Matrix.multGamma(sourceMatrixTranspose[_i7]); + } + + // do actual calculation for transformation matrix + var tempMatrix = Matrix.multMat(targetMatrixTranspose, Matrix.transpose(sourceMatrixTranspose)); // tempMatrix = A'B + var SVDResult = SVD.svd(tempMatrix); // SVD(A'B) = USV', svd function returns U, S and V + transformationMatrix = Matrix.multMat(SVDResult.V, Matrix.transpose(SVDResult.U)); // transformationMatrix = T = VU' + + /* apply found transformation matrix to obtain final draft layout */ + for (var _i8 = 0; _i8 < nodeIndexes.size; _i8++) { + var temp1 = [xCoords[_i8], yCoords[_i8]]; + var temp2 = [transformationMatrix[0][0], transformationMatrix[1][0]]; + var temp3 = [transformationMatrix[0][1], transformationMatrix[1][1]]; + xCoords[_i8] = Matrix.dotProduct(temp1, temp2); + yCoords[_i8] = Matrix.dotProduct(temp1, temp3); + } + + // applied only both alignment and rel. placement constraints exist + if (reflectionType) { + applyReflectionForRelativePlacement(constraints.relativePlacementConstraint); + } + } + } + + if (CoSEConstants.ENFORCE_CONSTRAINTS) { + /**** enforce constraints on the transformed draft layout ****/ + + /* first enforce fixed node constraint */ + + if (constraints.fixedNodeConstraint && constraints.fixedNodeConstraint.length > 0) { + var translationAmount = { x: 0, y: 0 }; + constraints.fixedNodeConstraint.forEach(function (nodeData, i) { + var posInTheory = { x: xCoords[nodeIndexes.get(nodeData.nodeId)], y: yCoords[nodeIndexes.get(nodeData.nodeId)] }; + var posDesired = nodeData.position; + var posDiff = calculatePositionDiff(posDesired, posInTheory); + translationAmount.x += posDiff.x; + translationAmount.y += posDiff.y; + }); + translationAmount.x /= constraints.fixedNodeConstraint.length; + translationAmount.y /= constraints.fixedNodeConstraint.length; + + xCoords.forEach(function (value, i) { + xCoords[i] += translationAmount.x; + }); + + yCoords.forEach(function (value, i) { + yCoords[i] += translationAmount.y; + }); + + constraints.fixedNodeConstraint.forEach(function (nodeData) { + xCoords[nodeIndexes.get(nodeData.nodeId)] = nodeData.position.x; + yCoords[nodeIndexes.get(nodeData.nodeId)] = nodeData.position.y; + }); + } + + /* then enforce alignment constraint */ + + if (constraints.alignmentConstraint) { + if (constraints.alignmentConstraint.vertical) { + var xAlign = constraints.alignmentConstraint.vertical; + + var _loop4 = function _loop4(_i9) { + var alignmentSet = new Set(); + xAlign[_i9].forEach(function (nodeId) { + alignmentSet.add(nodeId); + }); + var intersection = new Set([].concat(_toConsumableArray(alignmentSet)).filter(function (x) { + return fixedNodes.has(x); + })); + var xPos = void 0; + if (intersection.size > 0) xPos = xCoords[nodeIndexes.get(intersection.values().next().value)];else xPos = calculateAvgPosition(alignmentSet).x; + + alignmentSet.forEach(function (nodeId) { + if (!fixedNodes.has(nodeId)) xCoords[nodeIndexes.get(nodeId)] = xPos; + }); + }; + + for (var _i9 = 0; _i9 < xAlign.length; _i9++) { + _loop4(_i9); + } + } + if (constraints.alignmentConstraint.horizontal) { + var yAlign = constraints.alignmentConstraint.horizontal; + + var _loop5 = function _loop5(_i10) { + var alignmentSet = new Set(); + yAlign[_i10].forEach(function (nodeId) { + alignmentSet.add(nodeId); + }); + var intersection = new Set([].concat(_toConsumableArray(alignmentSet)).filter(function (x) { + return fixedNodes.has(x); + })); + var yPos = void 0; + if (intersection.size > 0) yPos = yCoords[nodeIndexes.get(intersection.values().next().value)];else yPos = calculateAvgPosition(alignmentSet).y; + + alignmentSet.forEach(function (nodeId) { + if (!fixedNodes.has(nodeId)) yCoords[nodeIndexes.get(nodeId)] = yPos; + }); + }; + + for (var _i10 = 0; _i10 < yAlign.length; _i10++) { + _loop5(_i10); + } + } + } + + /* finally enforce relative placement constraint */ + + if (constraints.relativePlacementConstraint) { + (function () { + var nodeToDummyForVerticalAlignment = new Map(); + var nodeToDummyForHorizontalAlignment = new Map(); + var dummyToNodeForVerticalAlignment = new Map(); + var dummyToNodeForHorizontalAlignment = new Map(); + var dummyPositionsForVerticalAlignment = new Map(); + var dummyPositionsForHorizontalAlignment = new Map(); + var fixedNodesOnHorizontal = new Set(); + var fixedNodesOnVertical = new Set(); + + // fill maps and sets + fixedNodes.forEach(function (nodeId) { + fixedNodesOnHorizontal.add(nodeId); + fixedNodesOnVertical.add(nodeId); + }); + + if (constraints.alignmentConstraint) { + if (constraints.alignmentConstraint.vertical) { + var verticalAlignment = constraints.alignmentConstraint.vertical; + + var _loop6 = function _loop6(_i11) { + dummyToNodeForVerticalAlignment.set("dummy" + _i11, []); + verticalAlignment[_i11].forEach(function (nodeId) { + nodeToDummyForVerticalAlignment.set(nodeId, "dummy" + _i11); + dummyToNodeForVerticalAlignment.get("dummy" + _i11).push(nodeId); + if (fixedNodes.has(nodeId)) { + fixedNodesOnHorizontal.add("dummy" + _i11); + } + }); + dummyPositionsForVerticalAlignment.set("dummy" + _i11, xCoords[nodeIndexes.get(verticalAlignment[_i11][0])]); + }; + + for (var _i11 = 0; _i11 < verticalAlignment.length; _i11++) { + _loop6(_i11); + } + } + if (constraints.alignmentConstraint.horizontal) { + var horizontalAlignment = constraints.alignmentConstraint.horizontal; + + var _loop7 = function _loop7(_i12) { + dummyToNodeForHorizontalAlignment.set("dummy" + _i12, []); + horizontalAlignment[_i12].forEach(function (nodeId) { + nodeToDummyForHorizontalAlignment.set(nodeId, "dummy" + _i12); + dummyToNodeForHorizontalAlignment.get("dummy" + _i12).push(nodeId); + if (fixedNodes.has(nodeId)) { + fixedNodesOnVertical.add("dummy" + _i12); + } + }); + dummyPositionsForHorizontalAlignment.set("dummy" + _i12, yCoords[nodeIndexes.get(horizontalAlignment[_i12][0])]); + }; + + for (var _i12 = 0; _i12 < horizontalAlignment.length; _i12++) { + _loop7(_i12); + } + } + } + + // construct horizontal and vertical dags (subgraphs) from overall dag + var dagOnHorizontal = new Map(); + var dagOnVertical = new Map(); + + var _loop8 = function _loop8(nodeId) { + dag.get(nodeId).forEach(function (adjacent) { + var sourceId = void 0; + var targetNode = void 0; + if (adjacent["direction"] == "horizontal") { + sourceId = nodeToDummyForVerticalAlignment.get(nodeId) ? nodeToDummyForVerticalAlignment.get(nodeId) : nodeId; + if (nodeToDummyForVerticalAlignment.get(adjacent.id)) { + targetNode = { id: nodeToDummyForVerticalAlignment.get(adjacent.id), gap: adjacent.gap, direction: adjacent.direction }; + } else { + targetNode = adjacent; + } + if (dagOnHorizontal.has(sourceId)) { + dagOnHorizontal.get(sourceId).push(targetNode); + } else { + dagOnHorizontal.set(sourceId, [targetNode]); + } + if (!dagOnHorizontal.has(targetNode.id)) { + dagOnHorizontal.set(targetNode.id, []); + } + } else { + sourceId = nodeToDummyForHorizontalAlignment.get(nodeId) ? nodeToDummyForHorizontalAlignment.get(nodeId) : nodeId; + if (nodeToDummyForHorizontalAlignment.get(adjacent.id)) { + targetNode = { id: nodeToDummyForHorizontalAlignment.get(adjacent.id), gap: adjacent.gap, direction: adjacent.direction }; + } else { + targetNode = adjacent; + } + if (dagOnVertical.has(sourceId)) { + dagOnVertical.get(sourceId).push(targetNode); + } else { + dagOnVertical.set(sourceId, [targetNode]); + } + if (!dagOnVertical.has(targetNode.id)) { + dagOnVertical.set(targetNode.id, []); + } + } + }); + }; + + var _iteratorNormalCompletion5 = true; + var _didIteratorError5 = false; + var _iteratorError5 = undefined; + + try { + for (var _iterator5 = dag.keys()[Symbol.iterator](), _step5; !(_iteratorNormalCompletion5 = (_step5 = _iterator5.next()).done); _iteratorNormalCompletion5 = true) { + var nodeId = _step5.value; + + _loop8(nodeId); + } + + // find source nodes of each component in horizontal and vertical dags + } catch (err) { + _didIteratorError5 = true; + _iteratorError5 = err; + } finally { + try { + if (!_iteratorNormalCompletion5 && _iterator5.return) { + _iterator5.return(); + } + } finally { + if (_didIteratorError5) { + throw _iteratorError5; + } + } + } + + var undirectedOnHorizontal = dagToUndirected(dagOnHorizontal); + var undirectedOnVertical = dagToUndirected(dagOnVertical); + var componentsOnHorizontal = findComponents(undirectedOnHorizontal); + var componentsOnVertical = findComponents(undirectedOnVertical); + var reversedDagOnHorizontal = dagToReversed(dagOnHorizontal); + var reversedDagOnVertical = dagToReversed(dagOnVertical); + var componentSourcesOnHorizontal = []; + var componentSourcesOnVertical = []; + + componentsOnHorizontal.forEach(function (component, index) { + componentSourcesOnHorizontal[index] = []; + component.forEach(function (nodeId) { + if (reversedDagOnHorizontal.get(nodeId).length == 0) { + componentSourcesOnHorizontal[index].push(nodeId); + } + }); + }); + + componentsOnVertical.forEach(function (component, index) { + componentSourcesOnVertical[index] = []; + component.forEach(function (nodeId) { + if (reversedDagOnVertical.get(nodeId).length == 0) { + componentSourcesOnVertical[index].push(nodeId); + } + }); + }); + + // calculate appropriate positioning for subgraphs + var positionMapHorizontal = findAppropriatePositionForRelativePlacement(dagOnHorizontal, "horizontal", fixedNodesOnHorizontal, dummyPositionsForVerticalAlignment, componentSourcesOnHorizontal); + var positionMapVertical = findAppropriatePositionForRelativePlacement(dagOnVertical, "vertical", fixedNodesOnVertical, dummyPositionsForHorizontalAlignment, componentSourcesOnVertical); + + // update positions of the nodes based on relative placement constraints + + var _loop9 = function _loop9(key) { + if (dummyToNodeForVerticalAlignment.get(key)) { + dummyToNodeForVerticalAlignment.get(key).forEach(function (nodeId) { + xCoords[nodeIndexes.get(nodeId)] = positionMapHorizontal.get(key); + }); + } else { + xCoords[nodeIndexes.get(key)] = positionMapHorizontal.get(key); + } + }; + + var _iteratorNormalCompletion6 = true; + var _didIteratorError6 = false; + var _iteratorError6 = undefined; + + try { + for (var _iterator6 = positionMapHorizontal.keys()[Symbol.iterator](), _step6; !(_iteratorNormalCompletion6 = (_step6 = _iterator6.next()).done); _iteratorNormalCompletion6 = true) { + var key = _step6.value; + + _loop9(key); + } + } catch (err) { + _didIteratorError6 = true; + _iteratorError6 = err; + } finally { + try { + if (!_iteratorNormalCompletion6 && _iterator6.return) { + _iterator6.return(); + } + } finally { + if (_didIteratorError6) { + throw _iteratorError6; + } + } + } + + var _loop10 = function _loop10(key) { + if (dummyToNodeForHorizontalAlignment.get(key)) { + dummyToNodeForHorizontalAlignment.get(key).forEach(function (nodeId) { + yCoords[nodeIndexes.get(nodeId)] = positionMapVertical.get(key); + }); + } else { + yCoords[nodeIndexes.get(key)] = positionMapVertical.get(key); + } + }; + + var _iteratorNormalCompletion7 = true; + var _didIteratorError7 = false; + var _iteratorError7 = undefined; + + try { + for (var _iterator7 = positionMapVertical.keys()[Symbol.iterator](), _step7; !(_iteratorNormalCompletion7 = (_step7 = _iterator7.next()).done); _iteratorNormalCompletion7 = true) { + var key = _step7.value; + + _loop10(key); + } + } catch (err) { + _didIteratorError7 = true; + _iteratorError7 = err; + } finally { + try { + if (!_iteratorNormalCompletion7 && _iterator7.return) { + _iterator7.return(); + } + } finally { + if (_didIteratorError7) { + throw _iteratorError7; + } + } + } + })(); + } + } + + // assign new coordinates to nodes after constraint handling + for (var _i13 = 0; _i13 < allNodes.length; _i13++) { + var _node = allNodes[_i13]; + if (_node.getChild() == null) { + _node.setCenter(xCoords[nodeIndexes.get(_node.id)], yCoords[nodeIndexes.get(_node.id)]); + } + } +}; + +module.exports = ConstraintHandler; + +/***/ }), + +/***/ 551: +/***/ ((module) => { + +module.exports = __WEBPACK_EXTERNAL_MODULE__551__; + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __webpack_require__(45); +/******/ +/******/ return __webpack_exports__; +/******/ })() +; +}); \ No newline at end of file diff --git a/js/cytoscape-cose-bilkent.js b/js/cytoscape-cose-bilkent.js new file mode 100755 index 0000000..1f138a8 --- /dev/null +++ b/js/cytoscape-cose-bilkent.js @@ -0,0 +1,458 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(require("cose-base")); + else if(typeof define === 'function' && define.amd) + define(["cose-base"], factory); + else if(typeof exports === 'object') + exports["cytoscapeCoseBilkent"] = factory(require("cose-base")); + else + root["cytoscapeCoseBilkent"] = factory(root["coseBase"]); +})(this, function(__WEBPACK_EXTERNAL_MODULE_0__) { +return /******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // identity function for calling harmony imports with the correct context +/******/ __webpack_require__.i = function(value) { return value; }; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 1); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE_0__; + +/***/ }), +/* 1 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var LayoutConstants = __webpack_require__(0).layoutBase.LayoutConstants; +var FDLayoutConstants = __webpack_require__(0).layoutBase.FDLayoutConstants; +var CoSEConstants = __webpack_require__(0).CoSEConstants; +var CoSELayout = __webpack_require__(0).CoSELayout; +var CoSENode = __webpack_require__(0).CoSENode; +var PointD = __webpack_require__(0).layoutBase.PointD; +var DimensionD = __webpack_require__(0).layoutBase.DimensionD; + +var defaults = { + // Called on `layoutready` + ready: function ready() {}, + // Called on `layoutstop` + stop: function stop() {}, + // 'draft', 'default' or 'proof" + // - 'draft' fast cooling rate + // - 'default' moderate cooling rate + // - "proof" slow cooling rate + quality: 'default', + // include labels in node dimensions + nodeDimensionsIncludeLabels: false, + // number of ticks per frame; higher is faster but more jerky + refresh: 30, + // Whether to fit the network view after when done + fit: true, + // Padding on fit + padding: 10, + // Whether to enable incremental mode + randomize: true, + // Node repulsion (non overlapping) multiplier + nodeRepulsion: 4500, + // Ideal edge (non nested) length + idealEdgeLength: 50, + // Divisor to compute edge forces + edgeElasticity: 0.45, + // Nesting factor (multiplier) to compute ideal edge length for nested edges + nestingFactor: 0.1, + // Gravity force (constant) + gravity: 0.25, + // Maximum number of iterations to perform + numIter: 2500, + // For enabling tiling + tile: true, + // Type of layout animation. The option set is {'during', 'end', false} + animate: 'end', + // Duration for animate:end + animationDuration: 500, + // Represents the amount of the vertical space to put between the zero degree members during the tiling operation(can also be a function) + tilingPaddingVertical: 10, + // Represents the amount of the horizontal space to put between the zero degree members during the tiling operation(can also be a function) + tilingPaddingHorizontal: 10, + // Gravity range (constant) for compounds + gravityRangeCompound: 1.5, + // Gravity force (constant) for compounds + gravityCompound: 1.0, + // Gravity range (constant) + gravityRange: 3.8, + // Initial cooling factor for incremental layout + initialEnergyOnIncremental: 0.5 +}; + +function extend(defaults, options) { + var obj = {}; + + for (var i in defaults) { + obj[i] = defaults[i]; + } + + for (var i in options) { + obj[i] = options[i]; + } + + return obj; +}; + +function _CoSELayout(_options) { + this.options = extend(defaults, _options); + getUserOptions(this.options); +} + +var getUserOptions = function getUserOptions(options) { + if (options.nodeRepulsion != null) CoSEConstants.DEFAULT_REPULSION_STRENGTH = FDLayoutConstants.DEFAULT_REPULSION_STRENGTH = options.nodeRepulsion; + if (options.idealEdgeLength != null) CoSEConstants.DEFAULT_EDGE_LENGTH = FDLayoutConstants.DEFAULT_EDGE_LENGTH = options.idealEdgeLength; + if (options.edgeElasticity != null) CoSEConstants.DEFAULT_SPRING_STRENGTH = FDLayoutConstants.DEFAULT_SPRING_STRENGTH = options.edgeElasticity; + if (options.nestingFactor != null) CoSEConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = FDLayoutConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = options.nestingFactor; + if (options.gravity != null) CoSEConstants.DEFAULT_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_GRAVITY_STRENGTH = options.gravity; + if (options.numIter != null) CoSEConstants.MAX_ITERATIONS = FDLayoutConstants.MAX_ITERATIONS = options.numIter; + if (options.gravityRange != null) CoSEConstants.DEFAULT_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_GRAVITY_RANGE_FACTOR = options.gravityRange; + if (options.gravityCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = options.gravityCompound; + if (options.gravityRangeCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = options.gravityRangeCompound; + if (options.initialEnergyOnIncremental != null) CoSEConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = options.initialEnergyOnIncremental; + + if (options.quality == 'draft') LayoutConstants.QUALITY = 0;else if (options.quality == 'proof') LayoutConstants.QUALITY = 2;else LayoutConstants.QUALITY = 1; + + CoSEConstants.NODE_DIMENSIONS_INCLUDE_LABELS = FDLayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = LayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = options.nodeDimensionsIncludeLabels; + CoSEConstants.DEFAULT_INCREMENTAL = FDLayoutConstants.DEFAULT_INCREMENTAL = LayoutConstants.DEFAULT_INCREMENTAL = !options.randomize; + CoSEConstants.ANIMATE = FDLayoutConstants.ANIMATE = LayoutConstants.ANIMATE = options.animate; + CoSEConstants.TILE = options.tile; + CoSEConstants.TILING_PADDING_VERTICAL = typeof options.tilingPaddingVertical === 'function' ? options.tilingPaddingVertical.call() : options.tilingPaddingVertical; + CoSEConstants.TILING_PADDING_HORIZONTAL = typeof options.tilingPaddingHorizontal === 'function' ? options.tilingPaddingHorizontal.call() : options.tilingPaddingHorizontal; +}; + +_CoSELayout.prototype.run = function () { + var ready; + var frameId; + var options = this.options; + var idToLNode = this.idToLNode = {}; + var layout = this.layout = new CoSELayout(); + var self = this; + + self.stopped = false; + + this.cy = this.options.cy; + + this.cy.trigger({ type: 'layoutstart', layout: this }); + + var gm = layout.newGraphManager(); + this.gm = gm; + + var nodes = this.options.eles.nodes(); + var edges = this.options.eles.edges(); + + this.root = gm.addRoot(); + this.processChildrenList(this.root, this.getTopMostNodes(nodes), layout); + + for (var i = 0; i < edges.length; i++) { + var edge = edges[i]; + var sourceNode = this.idToLNode[edge.data("source")]; + var targetNode = this.idToLNode[edge.data("target")]; + if (sourceNode !== targetNode && sourceNode.getEdgesBetween(targetNode).length == 0) { + var e1 = gm.add(layout.newEdge(), sourceNode, targetNode); + e1.id = edge.id(); + } + } + + var getPositions = function getPositions(ele, i) { + if (typeof ele === "number") { + ele = i; + } + var theId = ele.data('id'); + var lNode = self.idToLNode[theId]; + + return { + x: lNode.getRect().getCenterX(), + y: lNode.getRect().getCenterY() + }; + }; + + /* + * Reposition nodes in iterations animatedly + */ + var iterateAnimated = function iterateAnimated() { + // Thigs to perform after nodes are repositioned on screen + var afterReposition = function afterReposition() { + if (options.fit) { + options.cy.fit(options.eles, options.padding); + } + + if (!ready) { + ready = true; + self.cy.one('layoutready', options.ready); + self.cy.trigger({ type: 'layoutready', layout: self }); + } + }; + + var ticksPerFrame = self.options.refresh; + var isDone; + + for (var i = 0; i < ticksPerFrame && !isDone; i++) { + isDone = self.stopped || self.layout.tick(); + } + + // If layout is done + if (isDone) { + // If the layout is not a sublayout and it is successful perform post layout. + if (layout.checkLayoutSuccess() && !layout.isSubLayout) { + layout.doPostLayout(); + } + + // If layout has a tilingPostLayout function property call it. + if (layout.tilingPostLayout) { + layout.tilingPostLayout(); + } + + layout.isLayoutFinished = true; + + self.options.eles.nodes().positions(getPositions); + + afterReposition(); + + // trigger layoutstop when the layout stops (e.g. finishes) + self.cy.one('layoutstop', self.options.stop); + self.cy.trigger({ type: 'layoutstop', layout: self }); + + if (frameId) { + cancelAnimationFrame(frameId); + } + + ready = false; + return; + } + + var animationData = self.layout.getPositionsData(); // Get positions of layout nodes note that all nodes may not be layout nodes because of tiling + + // Position nodes, for the nodes whose id does not included in data (because they are removed from their parents and included in dummy compounds) + // use position of their ancestors or dummy ancestors + options.eles.nodes().positions(function (ele, i) { + if (typeof ele === "number") { + ele = i; + } + // If ele is a compound node, then its position will be defined by its children + if (!ele.isParent()) { + var theId = ele.id(); + var pNode = animationData[theId]; + var temp = ele; + // If pNode is undefined search until finding position data of its first ancestor (It may be dummy as well) + while (pNode == null) { + pNode = animationData[temp.data('parent')] || animationData['DummyCompound_' + temp.data('parent')]; + animationData[theId] = pNode; + temp = temp.parent()[0]; + if (temp == undefined) { + break; + } + } + if (pNode != null) { + return { + x: pNode.x, + y: pNode.y + }; + } else { + return { + x: ele.position('x'), + y: ele.position('y') + }; + } + } + }); + + afterReposition(); + + frameId = requestAnimationFrame(iterateAnimated); + }; + + /* + * Listen 'layoutstarted' event and start animated iteration if animate option is 'during' + */ + layout.addListener('layoutstarted', function () { + if (self.options.animate === 'during') { + frameId = requestAnimationFrame(iterateAnimated); + } + }); + + layout.runLayout(); // Run cose layout + + /* + * If animate option is not 'during' ('end' or false) perform these here (If it is 'during' similar things are already performed) + */ + if (this.options.animate !== "during") { + self.options.eles.nodes().not(":parent").layoutPositions(self, self.options, getPositions); // Use layout positions to reposition the nodes it considers the options parameter + ready = false; + } + + return this; // chaining +}; + +//Get the top most ones of a list of nodes +_CoSELayout.prototype.getTopMostNodes = function (nodes) { + var nodesMap = {}; + for (var i = 0; i < nodes.length; i++) { + nodesMap[nodes[i].id()] = true; + } + var roots = nodes.filter(function (ele, i) { + if (typeof ele === "number") { + ele = i; + } + var parent = ele.parent()[0]; + while (parent != null) { + if (nodesMap[parent.id()]) { + return false; + } + parent = parent.parent()[0]; + } + return true; + }); + + return roots; +}; + +_CoSELayout.prototype.processChildrenList = function (parent, children, layout) { + var size = children.length; + for (var i = 0; i < size; i++) { + var theChild = children[i]; + var children_of_children = theChild.children(); + var theNode; + + var dimensions = theChild.layoutDimensions({ + nodeDimensionsIncludeLabels: this.options.nodeDimensionsIncludeLabels + }); + + if (theChild.outerWidth() != null && theChild.outerHeight() != null) { + theNode = parent.add(new CoSENode(layout.graphManager, new PointD(theChild.position('x') - dimensions.w / 2, theChild.position('y') - dimensions.h / 2), new DimensionD(parseFloat(dimensions.w), parseFloat(dimensions.h)))); + } else { + theNode = parent.add(new CoSENode(this.graphManager)); + } + // Attach id to the layout node + theNode.id = theChild.data("id"); + // Attach the paddings of cy node to layout node + theNode.paddingLeft = parseInt(theChild.css('padding')); + theNode.paddingTop = parseInt(theChild.css('padding')); + theNode.paddingRight = parseInt(theChild.css('padding')); + theNode.paddingBottom = parseInt(theChild.css('padding')); + + //Attach the label properties to compound if labels will be included in node dimensions + if (this.options.nodeDimensionsIncludeLabels) { + if (theChild.isParent()) { + var labelWidth = theChild.boundingBox({ includeLabels: true, includeNodes: false }).w; + var labelHeight = theChild.boundingBox({ includeLabels: true, includeNodes: false }).h; + var labelPos = theChild.css("text-halign"); + theNode.labelWidth = labelWidth; + theNode.labelHeight = labelHeight; + theNode.labelPos = labelPos; + } + } + + // Map the layout node + this.idToLNode[theChild.data("id")] = theNode; + + if (isNaN(theNode.rect.x)) { + theNode.rect.x = 0; + } + + if (isNaN(theNode.rect.y)) { + theNode.rect.y = 0; + } + + if (children_of_children != null && children_of_children.length > 0) { + var theNewGraph; + theNewGraph = layout.getGraphManager().add(layout.newGraph(), theNode); + this.processChildrenList(theNewGraph, children_of_children, layout); + } + } +}; + +/** + * @brief : called on continuous layouts to stop them before they finish + */ +_CoSELayout.prototype.stop = function () { + this.stopped = true; + + return this; // chaining +}; + +var register = function register(cytoscape) { + // var Layout = getLayout( cytoscape ); + + cytoscape('layout', 'cose-bilkent', _CoSELayout); +}; + +// auto reg for globals +if (typeof cytoscape !== 'undefined') { + register(cytoscape); +} + +module.exports = register; + +/***/ }) +/******/ ]); +}); \ No newline at end of file diff --git a/js/cytoscape-dagre.js b/js/cytoscape-dagre.js new file mode 100755 index 0000000..8405c7f --- /dev/null +++ b/js/cytoscape-dagre.js @@ -0,0 +1,397 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(require("dagre")); + else if(typeof define === 'function' && define.amd) + define(["dagre"], factory); + else if(typeof exports === 'object') + exports["cytoscapeDagre"] = factory(require("dagre")); + else + root["cytoscapeDagre"] = factory(root["dagre"]); +})(this, function(__WEBPACK_EXTERNAL_MODULE__4__) { +return /******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +var impl = __webpack_require__(1); // registers the extension on a cytoscape lib ref + + +var register = function register(cytoscape) { + if (!cytoscape) { + return; + } // can't register if cytoscape unspecified + + + cytoscape('layout', 'dagre', impl); // register with cytoscape.js +}; + +if (typeof cytoscape !== 'undefined') { + // expose to global cytoscape (i.e. window.cytoscape) + register(cytoscape); +} + +module.exports = register; + +/***/ }), +/* 1 */ +/***/ (function(module, exports, __webpack_require__) { + +function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +var isFunction = function isFunction(o) { + return typeof o === 'function'; +}; + +var defaults = __webpack_require__(2); + +var assign = __webpack_require__(3); + +var dagre = __webpack_require__(4); // constructor +// options : object containing layout options + + +function DagreLayout(options) { + this.options = assign({}, defaults, options); +} // runs the layout + + +DagreLayout.prototype.run = function () { + var options = this.options; + var layout = this; + var cy = options.cy; // cy is automatically populated for us in the constructor + + var eles = options.eles; + + var getVal = function getVal(ele, val) { + return isFunction(val) ? val.apply(ele, [ele]) : val; + }; + + var bb = options.boundingBox || { + x1: 0, + y1: 0, + w: cy.width(), + h: cy.height() + }; + + if (bb.x2 === undefined) { + bb.x2 = bb.x1 + bb.w; + } + + if (bb.w === undefined) { + bb.w = bb.x2 - bb.x1; + } + + if (bb.y2 === undefined) { + bb.y2 = bb.y1 + bb.h; + } + + if (bb.h === undefined) { + bb.h = bb.y2 - bb.y1; + } + + var g = new dagre.graphlib.Graph({ + multigraph: true, + compound: true + }); + var gObj = {}; + + var setGObj = function setGObj(name, val) { + if (val != null) { + gObj[name] = val; + } + }; + + setGObj('nodesep', options.nodeSep); + setGObj('edgesep', options.edgeSep); + setGObj('ranksep', options.rankSep); + setGObj('rankdir', options.rankDir); + setGObj('align', options.align); + setGObj('ranker', options.ranker); + setGObj('acyclicer', options.acyclicer); + g.setGraph(gObj); + g.setDefaultEdgeLabel(function () { + return {}; + }); + g.setDefaultNodeLabel(function () { + return {}; + }); // add nodes to dagre + + var nodes = eles.nodes(); + + if (isFunction(options.sort)) { + nodes = nodes.sort(options.sort); + } + + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var nbb = node.layoutDimensions(options); + g.setNode(node.id(), { + width: nbb.w, + height: nbb.h, + name: node.id() + }); // console.log( g.node(node.id()) ); + } // set compound parents + + + for (var _i = 0; _i < nodes.length; _i++) { + var _node = nodes[_i]; + + if (_node.isChild()) { + g.setParent(_node.id(), _node.parent().id()); + } + } // add edges to dagre + + + var edges = eles.edges().stdFilter(function (edge) { + return !edge.source().isParent() && !edge.target().isParent(); // dagre can't handle edges on compound nodes + }); + + if (isFunction(options.sort)) { + edges = edges.sort(options.sort); + } + + for (var _i2 = 0; _i2 < edges.length; _i2++) { + var edge = edges[_i2]; + g.setEdge(edge.source().id(), edge.target().id(), { + minlen: getVal(edge, options.minLen), + weight: getVal(edge, options.edgeWeight), + name: edge.id() + }, edge.id()); // console.log( g.edge(edge.source().id(), edge.target().id(), edge.id()) ); + } + + dagre.layout(g); + var gNodeIds = g.nodes(); + + for (var _i3 = 0; _i3 < gNodeIds.length; _i3++) { + var id = gNodeIds[_i3]; + var n = g.node(id); + cy.getElementById(id).scratch().dagre = n; + } + + var dagreBB; + + if (options.boundingBox) { + dagreBB = { + x1: Infinity, + x2: -Infinity, + y1: Infinity, + y2: -Infinity + }; + nodes.forEach(function (node) { + var dModel = node.scratch().dagre; + dagreBB.x1 = Math.min(dagreBB.x1, dModel.x); + dagreBB.x2 = Math.max(dagreBB.x2, dModel.x); + dagreBB.y1 = Math.min(dagreBB.y1, dModel.y); + dagreBB.y2 = Math.max(dagreBB.y2, dModel.y); + }); + dagreBB.w = dagreBB.x2 - dagreBB.x1; + dagreBB.h = dagreBB.y2 - dagreBB.y1; + } else { + dagreBB = bb; + } + + var constrainPos = function constrainPos(p) { + if (options.boundingBox) { + var xPct = dagreBB.w === 0 ? 0 : (p.x - dagreBB.x1) / dagreBB.w; + var yPct = dagreBB.h === 0 ? 0 : (p.y - dagreBB.y1) / dagreBB.h; + return { + x: bb.x1 + xPct * bb.w, + y: bb.y1 + yPct * bb.h + }; + } else { + return p; + } + }; + + nodes.layoutPositions(layout, options, function (ele) { + ele = _typeof(ele) === "object" ? ele : this; + var dModel = ele.scratch().dagre; + return constrainPos({ + x: dModel.x, + y: dModel.y + }); + }); + return this; // chaining +}; + +module.exports = DagreLayout; + +/***/ }), +/* 2 */ +/***/ (function(module, exports) { + +var defaults = { + // dagre algo options, uses default value on undefined + nodeSep: undefined, + // the separation between adjacent nodes in the same rank + edgeSep: undefined, + // the separation between adjacent edges in the same rank + rankSep: undefined, + // the separation between adjacent nodes in the same rank + rankDir: undefined, + // 'TB' for top to bottom flow, 'LR' for left to right, + align: undefined, + // alignment for rank nodes. Can be 'UL', 'UR', 'DL', or 'DR', where U = up, D = down, L = left, and R = right + acyclicer: undefined, + // If set to 'greedy', uses a greedy heuristic for finding a feedback arc set for a graph. + // A feedback arc set is a set of edges that can be removed to make a graph acyclic. + ranker: undefined, + // Type of algorithm to assigns a rank to each node in the input graph. + // Possible values: network-simplex, tight-tree or longest-path + minLen: function minLen(edge) { + return 1; + }, + // number of ranks to keep between the source and target of the edge + edgeWeight: function edgeWeight(edge) { + return 1; + }, + // higher weight edges are generally made shorter and straighter than lower weight edges + // general layout options + fit: true, + // whether to fit to viewport + padding: 30, + // fit padding + spacingFactor: undefined, + // Applies a multiplicative factor (>0) to expand or compress the overall area that the nodes take up + nodeDimensionsIncludeLabels: false, + // whether labels should be included in determining the space used by a node + animate: false, + // whether to transition the node positions + animateFilter: function animateFilter(node, i) { + return true; + }, + // whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions + animationDuration: 500, + // duration of animation in ms if enabled + animationEasing: undefined, + // easing of animation if enabled + boundingBox: undefined, + // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } + transform: function transform(node, pos) { + return pos; + }, + // a function that applies a transform to the final node position + ready: function ready() {}, + // on layoutready + sort: undefined, + // a sorting function to order the nodes and edges; e.g. function(a, b){ return a.data('weight') - b.data('weight') } + // because cytoscape dagre creates a directed graph, and directed graphs use the node order as a tie breaker when + // defining the topology of a graph, this sort function can help ensure the correct order of the nodes/edges. + // this feature is most useful when adding and removing the same nodes and edges multiple times in a graph. + stop: function stop() {} // on layoutstop + +}; +module.exports = defaults; + +/***/ }), +/* 3 */ +/***/ (function(module, exports) { + +// Simple, internal Object.assign() polyfill for options objects etc. +module.exports = Object.assign != null ? Object.assign.bind(Object) : function (tgt) { + for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + srcs[_key - 1] = arguments[_key]; + } + + srcs.forEach(function (src) { + Object.keys(src).forEach(function (k) { + return tgt[k] = src[k]; + }); + }); + return tgt; +}; + +/***/ }), +/* 4 */ +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE__4__; + +/***/ }) +/******/ ]); +}); \ No newline at end of file diff --git a/js/cytoscape.min.js b/js/cytoscape.min.js new file mode 100755 index 0000000..f36c15d --- /dev/null +++ b/js/cytoscape.min.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2016-2024, The Cytoscape Consortium. + * + * 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. + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}var u="undefined"==typeof window?null:window,c=u?u.navigator:null;u&&u.document;var d=e(""),h=e({}),p=e((function(){})),f="undefined"==typeof HTMLElement?"undefined":e(HTMLElement),g=function(e){return e&&e.instanceString&&y(e.instanceString)?e.instanceString():null},v=function(t){return null!=t&&e(t)==d},y=function(t){return null!=t&&e(t)===p},m=function(e){return!E(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},b=function(t){return null!=t&&e(t)===h&&!m(t)&&t.constructor===Object},x=function(t){return null!=t&&e(t)===e(1)&&!isNaN(t)},w=function(e){return"undefined"===f?void 0:null!=e&&e instanceof HTMLElement},E=function(e){return k(e)||C(e)},k=function(e){return"collection"===g(e)&&e._private.single},C=function(e){return"collection"===g(e)&&!e._private.single},S=function(e){return"core"===g(e)},P=function(e){return"stylesheet"===g(e)},D=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},T=function(t){return function(t){return null!=t&&e(t)===h}(t)&&y(t.then)},_=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;tt?1:0},L=null!=Object.assign?Object.assign.bind(Object):function(e){for(var t=arguments,n=1;n255)return;t.push(Math.floor(a))}var o=r[1]||r[2]||r[3],s=r[1]&&r[2]&&r[3];if(o&&!s)return;var l=n[4];if(void 0!==l){if((l=parseFloat(l))<0||l>1)return;t.push(l)}}return t}(e)||function(e){var t,n,r,i,a,o,s,l;function u(e,t,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?e+6*(t-e)*n:n<.5?t:n<2/3?e+(t-e)*(2/3-n)*6:e}var c=new RegExp("^hsl[a]?\\(((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?)))\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%])(?:\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))))?\\)$").exec(e);if(c){if((n=parseInt(c[1]))<0?n=(360- -1*n%360)%360:n>360&&(n%=360),n/=360,(r=parseFloat(c[2]))<0||r>100)return;if(r/=100,(i=parseFloat(c[3]))<0||i>100)return;if(i/=100,void 0!==(a=c[4])&&((a=parseFloat(a))<0||a>1))return;if(0===r)o=s=l=Math.round(255*i);else{var d=i<.5?i*(1+r):i+r-i*r,h=2*i-d;o=Math.round(255*u(h,d,n+1/3)),s=Math.round(255*u(h,d,n)),l=Math.round(255*u(h,d,n-1/3))}t=[o,s,l,a]}return t}(e)},R={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},V=function(e){for(var t=e.map,n=e.keys,r=n.length,i=0;i=t||n<0||d&&e-u>=a}function v(){var e=H();if(g(e))return y(e);s=setTimeout(v,function(e){var n=t-(e-l);return d?ge(n,a-(e-u)):n}(e))}function y(e){return s=void 0,h&&r?p(e):(r=i=void 0,o)}function m(){var e=H(),n=g(e);if(r=arguments,i=this,l=e,n){if(void 0===s)return f(l);if(d)return clearTimeout(s),s=setTimeout(v,t),p(l)}return void 0===s&&(s=setTimeout(v,t)),o}return t=pe(t)||0,j(n)&&(c=!!n.leading,a=(d="maxWait"in n)?fe(pe(n.maxWait)||0,t):a,h="trailing"in n?!!n.trailing:h),m.cancel=function(){void 0!==s&&clearTimeout(s),u=0,r=l=i=s=void 0},m.flush=function(){return void 0===s?o:y(H())},m},ye=u?u.performance:null,me=ye&&ye.now?function(){return ye.now()}:function(){return Date.now()},be=function(){if(u){if(u.requestAnimationFrame)return function(e){u.requestAnimationFrame(e)};if(u.mozRequestAnimationFrame)return function(e){u.mozRequestAnimationFrame(e)};if(u.webkitRequestAnimationFrame)return function(e){u.webkitRequestAnimationFrame(e)};if(u.msRequestAnimationFrame)return function(e){u.msRequestAnimationFrame(e)}}return function(e){e&&setTimeout((function(){e(me())}),1e3/60)}}(),xe=function(e){return be(e)},we=me,Ee=65599,ke=function(e){for(var t,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261,r=n;!(t=e.next()).done;)r=r*Ee+t.value|0;return r},Ce=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:9261;return t*Ee+e|0},Se=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:5381;return(t<<5)+t+e|0},Pe=function(e){return 2097152*e[0]+e[1]},De=function(e,t){return[Ce(e[0],t[0]),Se(e[1],t[1])]},Te=function(e,t){var n={value:0,done:!1},r=0,i=e.length;return ke({next:function(){return r=0&&(e[r]!==t||(e.splice(r,1),!n));r--);},Ge=function(e){e.splice(0,e.length)},Ue=function(e,t,n){return n&&(t=N(n,t)),e[t]},Ze=function(e,t,n,r){n&&(t=N(n,t)),e[t]=r},$e="undefined"!=typeof Map?Map:function(){function e(){t(this,e),this._obj={}}return r(e,[{key:"set",value:function(e,t){return this._obj[e]=t,this}},{key:"delete",value:function(e){return this._obj[e]=void 0,this}},{key:"clear",value:function(){this._obj={}}},{key:"has",value:function(e){return void 0!==this._obj[e]}},{key:"get",value:function(e){return this._obj[e]}}]),e}(),Qe=function(){function e(n){if(t(this,e),this._obj=Object.create(null),this.size=0,null!=n){var r;r=null!=n.instanceString&&n.instanceString()===this.instanceString()?n.toArray():n;for(var i=0;i2&&void 0!==arguments[2])||arguments[2];if(void 0!==e&&void 0!==t&&S(e)){var r=t.group;if(null==r&&(r=t.data&&null!=t.data.source&&null!=t.data.target?"edges":"nodes"),"nodes"===r||"edges"===r){this.length=1,this[0]=this;var i=this._private={cy:e,single:!0,data:t.data||{},position:t.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:r,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!t.selected,selectable:void 0===t.selectable||!!t.selectable,locked:!!t.locked,grabbed:!1,grabbable:void 0===t.grabbable||!!t.grabbable,pannable:void 0===t.pannable?"edges"===r:!!t.pannable,active:!1,classes:new Je,animation:{current:[],queue:[]},rscratch:{},scratch:t.scratch||{},edges:[],children:[],parent:t.parent&&t.parent.isNode()?t.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(null==i.position.x&&(i.position.x=0),null==i.position.y&&(i.position.y=0),t.renderedPosition){var a=t.renderedPosition,o=e.pan(),s=e.zoom();i.position={x:(a.x-o.x)/s,y:(a.y-o.y)/s}}var l=[];m(t.classes)?l=t.classes:v(t.classes)&&(l=t.classes.split(/\s+/));for(var u=0,c=l.length;ut?1:0},u=function(e,t,i,a,o){var s;if(null==i&&(i=0),null==o&&(o=n),i<0)throw new Error("lo must be non-negative");for(null==a&&(a=e.length);in;0<=n?t++:t--)u.push(t);return u}.apply(this).reverse()).length;ag;0<=g?++h:--h)v.push(a(e,r));return v},f=function(e,t,r,i){var a,o,s;for(null==i&&(i=n),a=e[r];r>t&&i(a,o=e[s=r-1>>1])<0;)e[r]=o,r=s;return e[r]=a},g=function(e,t,r){var i,a,o,s,l;for(null==r&&(r=n),a=e.length,l=t,o=e[t],i=2*t+1;i0;){var k=m.pop(),C=g(k),S=k.id();if(d[S]=C,C!==1/0)for(var P=k.neighborhood().intersect(p),D=0;D0)for(n.unshift(t);c[i];){var a=c[i];n.unshift(a.edge),n.unshift(a.node),i=(r=a.node).id()}return o.spawn(n)}}}},ot={kruskal:function(e){e=e||function(e){return 1};for(var t=this.byGroup(),n=t.nodes,r=t.edges,i=n.length,a=new Array(i),o=n,s=function(e){for(var t=0;t0;){if(l=g.pop(),u=l.id(),v.delete(u),w++,u===d){for(var E=[],k=i,C=d,S=m[C];E.unshift(k),null!=S&&E.unshift(S),null!=(k=y[C]);)S=m[C=k.id()];return{found:!0,distance:h[u],path:this.spawn(E),steps:w}}f[u]=!0;for(var P=l._private.edges,D=0;DD&&(p[P]=D,m[P]=S,b[P]=w),!i){var T=S*u+C;!i&&p[T]>D&&(p[T]=D,m[T]=C,b[T]=w)}}}for(var _=0;_1&&void 0!==arguments[1]?arguments[1]:a,r=b(e),i=[],o=r;;){if(null==o)return t.spawn();var l=m(o),u=l.edge,c=l.pred;if(i.unshift(o[0]),o.same(n)&&i.length>0)break;null!=u&&i.unshift(u),o=c}return s.spawn(i)},hasNegativeWeightCycle:f,negativeWeightCycles:g}}},pt=Math.sqrt(2),ft=function(e,t,n){0===n.length&&Ve("Karger-Stein must be run on a connected (sub)graph");for(var r=n[e],i=r[1],a=r[2],o=t[i],s=t[a],l=n,u=l.length-1;u>=0;u--){var c=l[u],d=c[1],h=c[2];(t[d]===o&&t[h]===s||t[d]===s&&t[h]===o)&&l.splice(u,1)}for(var p=0;pr;){var i=Math.floor(Math.random()*t.length);t=ft(i,e,t),n--}return t},vt={kargerStein:function(){var e=this,t=this.byGroup(),n=t.nodes,r=t.edges;r.unmergeBy((function(e){return e.isLoop()}));var i=n.length,a=r.length,o=Math.ceil(Math.pow(Math.log(i)/Math.LN2,2)),s=Math.floor(i/pt);if(!(i<2)){for(var l=[],u=0;u0?1:e<0?-1:0},kt=function(e,t){return Math.sqrt(Ct(e,t))},Ct=function(e,t){var n=t.x-e.x,r=t.y-e.y;return n*n+r*r},St=function(e){for(var t=e.length,n=0,r=0;r=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(null!=e.w&&null!=e.h&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},Mt=function(e,t){e.x1=Math.min(e.x1,t.x1),e.x2=Math.max(e.x2,t.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,t.y1),e.y2=Math.max(e.y2,t.y2),e.h=e.y2-e.y1},Bt=function(e,t,n){e.x1=Math.min(e.x1,t),e.x2=Math.max(e.x2,t),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},Nt=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return e.x1-=t,e.x2+=t,e.y1-=t,e.y2+=t,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},zt=function(e){var t,n,r,i,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0];if(1===o.length)t=n=r=i=o[0];else if(2===o.length)t=r=o[0],i=n=o[1];else if(4===o.length){var s=a(o,4);t=s[0],n=s[1],r=s[2],i=s[3]}return e.x1-=i,e.x2+=n,e.y1-=t,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},It=function(e,t){e.x1=t.x1,e.y1=t.y1,e.x2=t.x2,e.y2=t.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},At=function(e,t){return!(e.x1>t.x2)&&(!(t.x1>e.x2)&&(!(e.x2t.y2)&&!(t.y1>e.y2)))))))},Lt=function(e,t,n){return e.x1<=t&&t<=e.x2&&e.y1<=n&&n<=e.y2},Ot=function(e,t){return Lt(e,t.x1,t.y1)&&Lt(e,t.x2,t.y2)},Rt=function(e,t,n,r,i,a,o){var s,l,u=arguments.length>7&&void 0!==arguments[7]?arguments[7]:"auto",c="auto"===u?nn(i,a):u,d=i/2,h=a/2,p=(c=Math.min(c,d,h))!==d,f=c!==h;if(p){var g=n-d+c-o,v=r-h-o,y=n+d-c+o,m=v;if((s=Zt(e,t,n,r,g,v,y,m,!1)).length>0)return s}if(f){var b=n+d+o,x=r-h+c-o,w=b,E=r+h-c+o;if((s=Zt(e,t,n,r,b,x,w,E,!1)).length>0)return s}if(p){var k=n-d+c-o,C=r+h+o,S=n+d-c+o,P=C;if((s=Zt(e,t,n,r,k,C,S,P,!1)).length>0)return s}if(f){var D=n-d-o,T=r-h+c-o,_=D,M=r+h-c+o;if((s=Zt(e,t,n,r,D,T,_,M,!1)).length>0)return s}var B=n-d+c,N=r-h+c;if((l=Gt(e,t,n,r,B,N,c+o)).length>0&&l[0]<=B&&l[1]<=N)return[l[0],l[1]];var z=n+d-c,I=r-h+c;if((l=Gt(e,t,n,r,z,I,c+o)).length>0&&l[0]>=z&&l[1]<=I)return[l[0],l[1]];var A=n+d-c,L=r+h-c;if((l=Gt(e,t,n,r,A,L,c+o)).length>0&&l[0]>=A&&l[1]>=L)return[l[0],l[1]];var O=n-d+c,R=r+h-c;return(l=Gt(e,t,n,r,O,R,c+o)).length>0&&l[0]<=O&&l[1]>=R?[l[0],l[1]]:[]},Vt=function(e,t,n,r,i,a,o){var s=o,l=Math.min(n,i),u=Math.max(n,i),c=Math.min(r,a),d=Math.max(r,a);return l-s<=e&&e<=u+s&&c-s<=t&&t<=d+s},Ft=function(e,t,n,r,i,a,o,s,l){var u=Math.min(n,o,i)-l,c=Math.max(n,o,i)+l,d=Math.min(r,s,a)-l,h=Math.max(r,s,a)+l;return!(ec||th)},jt=function(e,t,n,r,i,a,o,s){var l=[];!function(e,t,n,r,i){var a,o,s,l,u,c,d,h;0===e&&(e=1e-5),s=-27*(r/=e)+(t/=e)*(9*(n/=e)-t*t*2),a=(o=(3*n-t*t)/9)*o*o+(s/=54)*s,i[1]=0,d=t/3,a>0?(u=(u=s+Math.sqrt(a))<0?-Math.pow(-u,1/3):Math.pow(u,1/3),c=(c=s-Math.sqrt(a))<0?-Math.pow(-c,1/3):Math.pow(c,1/3),i[0]=-d+u+c,d+=(u+c)/2,i[4]=i[2]=-d,d=Math.sqrt(3)*(-c+u)/2,i[3]=d,i[5]=-d):(i[5]=i[3]=0,0===a?(h=s<0?-Math.pow(-s,1/3):Math.pow(s,1/3),i[0]=2*h-d,i[4]=i[2]=-(h+d)):(l=(o=-o)*o*o,l=Math.acos(s/Math.sqrt(l)),h=2*Math.sqrt(o),i[0]=-d+h*Math.cos(l/3),i[2]=-d+h*Math.cos((l+2*Math.PI)/3),i[4]=-d+h*Math.cos((l+4*Math.PI)/3)))}(1*n*n-4*n*i+2*n*o+4*i*i-4*i*o+o*o+r*r-4*r*a+2*r*s+4*a*a-4*a*s+s*s,9*n*i-3*n*n-3*n*o-6*i*i+3*i*o+9*r*a-3*r*r-3*r*s-6*a*a+3*a*s,3*n*n-6*n*i+n*o-n*e+2*i*i+2*i*e-o*e+3*r*r-6*r*a+r*s-r*t+2*a*a+2*a*t-s*t,1*n*i-n*n+n*e-i*e+r*a-r*r+r*t-a*t,l);for(var u=[],c=0;c<6;c+=2)Math.abs(l[c+1])<1e-7&&l[c]>=0&&l[c]<=1&&u.push(l[c]);u.push(1),u.push(0);for(var d,h,p,f=-1,g=0;g=0?pl?(e-i)*(e-i)+(t-a)*(t-a):u-d},Yt=function(e,t,n){for(var r,i,a,o,s=0,l=0;l=e&&e>=a||r<=e&&e<=a))continue;(e-r)/(a-r)*(o-i)+i>t&&s++}return s%2!=0},Xt=function(e,t,n,r,i,a,o,s,l){var u,c=new Array(n.length);null!=s[0]?(u=Math.atan(s[1]/s[0]),s[0]<0?u+=Math.PI/2:u=-u-Math.PI/2):u=s;for(var d,h=Math.cos(-u),p=Math.sin(-u),f=0;f0){var g=Ht(c,-l);d=Wt(g)}else d=c;return Yt(e,t,d)},Wt=function(e){for(var t,n,r,i,a,o,s,l,u=new Array(e.length/2),c=0;c=0&&f<=1&&v.push(f),g>=0&&g<=1&&v.push(g),0===v.length)return[];var y=v[0]*s[0]+e,m=v[0]*s[1]+t;return v.length>1?v[0]==v[1]?[y,m]:[y,m,v[1]*s[0]+e,v[1]*s[1]+t]:[y,m]},Ut=function(e,t,n){return t<=e&&e<=n||n<=e&&e<=t?e:e<=t&&t<=n||n<=t&&t<=e?t:n},Zt=function(e,t,n,r,i,a,o,s,l){var u=e-i,c=n-e,d=o-i,h=t-a,p=r-t,f=s-a,g=d*h-f*u,v=c*h-p*u,y=f*c-d*p;if(0!==y){var m=g/y,b=v/y;return-.001<=m&&m<=1.001&&-.001<=b&&b<=1.001||l?[e+m*c,t+m*p]:[]}return 0===g||0===v?Ut(e,n,o)===o?[o,s]:Ut(e,n,i)===i?[i,a]:Ut(i,o,n)===n?[n,r]:[]:[]},$t=function(e,t,n,r,i,a,o,s){var l,u,c,d,h,p,f=[],g=new Array(n.length),v=!0;if(null==a&&(v=!1),v){for(var y=0;y0){var m=Ht(g,-s);u=Wt(m)}else u=g}else u=n;for(var b=0;bu&&(u=t)},d=function(e){return l[e]},h=0;h0?b.edgesTo(m)[0]:m.edgesTo(b)[0];var w=r(x);m=m.id(),h[m]>h[v]+w&&(h[m]=h[v]+w,p.nodes.indexOf(m)<0?p.push(m):p.updateItem(m),u[m]=0,l[m]=[]),h[m]==h[v]+w&&(u[m]=u[m]+u[v],l[m].push(v))}else for(var E=0;E0;){for(var P=n.pop(),D=0;D0&&o.push(n[s]);0!==o.length&&i.push(r.collection(o))}return i}(c,l,t,r);return b=function(e){for(var t=0;t5&&void 0!==arguments[5]?arguments[5]:Cn,o=r,s=0;s=2?Mn(e,t,n,0,Dn,Tn):Mn(e,t,n,0,Pn)},squaredEuclidean:function(e,t,n){return Mn(e,t,n,0,Dn)},manhattan:function(e,t,n){return Mn(e,t,n,0,Pn)},max:function(e,t,n){return Mn(e,t,n,-1/0,_n)}};function Nn(e,t,n,r,i,a){var o;return o=y(e)?e:Bn[e]||Bn.euclidean,0===t&&y(e)?o(i,a):o(t,n,r,i,a)}Bn["squared-euclidean"]=Bn.squaredEuclidean,Bn.squaredeuclidean=Bn.squaredEuclidean;var zn=He({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),In=function(e){return zn(e)},An=function(e,t,n,r,i){var a="kMedoids"!==i?function(e){return n[e]}:function(e){return r[e](n)},o=n,s=t;return Nn(e,r.length,a,(function(e){return r[e](t)}),o,s)},Ln=function(e,t,n){for(var r=n.length,i=new Array(r),a=new Array(r),o=new Array(t),s=null,l=0;ln)return!1}return!0},jn=function(e,t,n){for(var r=0;ri&&(i=t[l][u],a=u);o[a].push(e[l])}for(var c=0;c=i.threshold||"dendrogram"===i.mode&&1===e.length)return!1;var p,f=t[o],g=t[r[o]];p="dendrogram"===i.mode?{left:f,right:g,key:f.key}:{value:f.value.concat(g.value),key:f.key},e[f.index]=p,e.splice(g.index,1),t[f.key]=p;for(var v=0;vn[g.key][y.key]&&(a=n[g.key][y.key])):"max"===i.linkage?(a=n[f.key][y.key],n[f.key][y.key]1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];r?e=e.slice(t,n):(n0&&e.splice(0,t));for(var o=0,s=e.length-1;s>=0;s--){var l=e[s];a?isFinite(l)||(e[s]=-1/0,o++):e.splice(s,1)}i&&e.sort((function(e,t){return e-t}));var u=e.length,c=Math.floor(u/2);return u%2!=0?e[c+1+o]:(e[c-1+o]+e[c+o])/2}(e):"mean"===t?function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=0,i=0,a=t;a1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=1/0,i=t;i1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length,r=-1/0,i=t;io&&(a=l,o=t[i*e+l])}a>0&&r.push(a)}for(var u=0;u=D?(T=D,D=M,_=B):M>T&&(T=M);for(var N=0;N0?1:0;C[k%u.minIterations*t+R]=V,O+=V}if(O>0&&(k>=u.minIterations-1||k==u.maxIterations-1)){for(var F=0,j=0;j0&&r.push(i);return r}(t,a,o),X=function(e,t,n){for(var r=rr(e,t,n),i=0;il&&(s=u,l=c)}n[i]=a[s]}return r=rr(e,t,n)}(t,r,Y),W={},H=0;H1)}}));var l=Object.keys(t).filter((function(e){return t[e].cutVertex})).map((function(t){return e.getElementById(t)}));return{cut:e.spawn(l),components:i}},lr=function(){var e=this,t={},n=0,r=[],i=[],a=e.spawn(e);return e.forEach((function(o){if(o.isNode()){var s=o.id();s in t||function o(s){if(i.push(s),t[s]={index:n,low:n++,explored:!1},e.getElementById(s).connectedEdges().intersection(e).forEach((function(e){var n=e.target().id();n!==s&&(n in t||o(n),t[n].explored||(t[s].low=Math.min(t[s].low,t[n].low)))})),t[s].index===t[s].low){for(var l=e.spawn();;){var u=i.pop();if(l.merge(e.getElementById(u)),t[u].low=t[s].index,t[u].explored=!0,u===s)break}var c=l.edgesWith(l),d=l.merge(c);r.push(d),a=a.difference(d)}}(s)}})),{cut:a,components:r}},ur={};[nt,at,ot,lt,ct,ht,vt,sn,un,dn,pn,kn,Kn,Jn,ar,{hierholzer:function(e){if(!b(e)){var t=arguments;e={root:t[0],directed:t[1]}}var n,r,i,a=or(e),o=a.root,s=a.directed,l=this,u=!1;o&&(i=v(o)?this.filter(o)[0].id():o[0].id());var c={},d={};s?l.forEach((function(e){var t=e.id();if(e.isNode()){var i=e.indegree(!0),a=e.outdegree(!0),o=i-a,s=a-i;1==o?n?u=!0:n=t:1==s?r?u=!0:r=t:(s>1||o>1)&&(u=!0),c[t]=[],e.outgoers().forEach((function(e){e.isEdge()&&c[t].push(e.id())}))}else d[t]=[void 0,e.target().id()]})):l.forEach((function(e){var t=e.id();e.isNode()?(e.degree(!0)%2&&(n?r?u=!0:r=t:n=t),c[t]=[],e.connectedEdges().forEach((function(e){return c[t].push(e.id())}))):d[t]=[e.source().id(),e.target().id()]}));var h={found:!1,trail:void 0};if(u)return h;if(r&&n)if(s){if(i&&r!=i)return h;i=r}else{if(i&&r!=i&&n!=i)return h;i||(i=r)}else i||(i=l[0].id());var p=function(e){for(var t,n,r,i=e,a=[e];c[i].length;)t=c[i].shift(),n=d[t][0],i!=(r=d[t][1])?(c[r]=c[r].filter((function(e){return e!=t})),i=r):s||i==n||(c[n]=c[n].filter((function(e){return e!=t})),i=n),a.unshift(t),a.unshift(i);return a},f=[],g=[];for(g=p(i);1!=g.length;)0==c[g[0]].length?(f.unshift(l.getElementById(g.shift())),f.unshift(l.getElementById(g.shift()))):g=p(g.shift()).concat(g);for(var y in f.unshift(l.getElementById(g.shift())),c)if(c[y].length)return h;return h.found=!0,h.trail=this.spawn(f,!0),h}},{hopcroftTarjanBiconnected:sr,htbc:sr,htb:sr,hopcroftTarjanBiconnectedComponents:sr},{tarjanStronglyConnected:lr,tsc:lr,tscc:lr,tarjanStronglyConnectedComponents:lr}].forEach((function(e){L(ur,e)})); +/*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + */ +var cr=function e(t){if(!(this instanceof e))return new e(t);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof t&&t.call(this,this.fulfill.bind(this),this.reject.bind(this))};cr.prototype={fulfill:function(e){return dr(this,1,"fulfillValue",e)},reject:function(e){return dr(this,2,"rejectReason",e)},then:function(e,t){var n=new cr;return this.onFulfilled.push(fr(e,n,"fulfill")),this.onRejected.push(fr(t,n,"reject")),hr(this),n.proxy}};var dr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,hr(e)),e},hr=function(e){1===e.state?pr(e,"onFulfilled",e.fulfillValue):2===e.state&&pr(e,"onRejected",e.rejectReason)},pr=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var i=function(){for(var e=0;e0:void 0}},clearQueue:function(){return function(){var e=void 0!==this.length?this:[this];if(!(this._private.cy||this).styleEnabled())return this;for(var t=0;t-1};var ri=function(e,t){var n=this.__data__,r=Qr(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this};function ii(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&e%1==0&&e0&&this.spawn(n).updateStyle().emit("class"),this},addClass:function(e){return this.toggleClass(e,!0)},hasClass:function(e){var t=this[0];return null!=t&&t._private.classes.has(e)},toggleClass:function(e,t){m(e)||(e=e.match(/\S+/g)||[]);for(var n=void 0===t,r=[],i=0,a=this.length;i0&&this.spawn(r).updateStyle().emit("class"),this},removeClass:function(e){return this.toggleClass(e,!1)},flashClass:function(e,t){var n=this;if(null==t)t=250;else if(0===t)return n;return n.addClass(e),setTimeout((function(){n.removeClass(e)}),t),n}};qi.className=qi.classNames=qi.classes;var Yi={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:"\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'",number:I,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Yi.variable="(?:[\\w-.]|(?:\\\\"+Yi.metaChar+"))+",Yi.className="(?:[\\w-]|(?:\\\\"+Yi.metaChar+"))+",Yi.value=Yi.string+"|"+Yi.number,Yi.id=Yi.variable,function(){var e,t,n;for(e=Yi.comparatorOp.split("|"),n=0;n=0||"="!==t&&(Yi.comparatorOp+="|\\!"+t)}();var Xi=0,Wi=1,Hi=2,Ki=3,Gi=4,Ui=5,Zi=6,$i=7,Qi=8,Ji=9,ea=10,ta=11,na=12,ra=13,ia=14,aa=15,oa=16,sa=17,la=18,ua=19,ca=20,da=[{selector:":selected",matches:function(e){return e.selected()}},{selector:":unselected",matches:function(e){return!e.selected()}},{selector:":selectable",matches:function(e){return e.selectable()}},{selector:":unselectable",matches:function(e){return!e.selectable()}},{selector:":locked",matches:function(e){return e.locked()}},{selector:":unlocked",matches:function(e){return!e.locked()}},{selector:":visible",matches:function(e){return e.visible()}},{selector:":hidden",matches:function(e){return!e.visible()}},{selector:":transparent",matches:function(e){return e.transparent()}},{selector:":grabbed",matches:function(e){return e.grabbed()}},{selector:":free",matches:function(e){return!e.grabbed()}},{selector:":removed",matches:function(e){return e.removed()}},{selector:":inside",matches:function(e){return!e.removed()}},{selector:":grabbable",matches:function(e){return e.grabbable()}},{selector:":ungrabbable",matches:function(e){return!e.grabbable()}},{selector:":animated",matches:function(e){return e.animated()}},{selector:":unanimated",matches:function(e){return!e.animated()}},{selector:":parent",matches:function(e){return e.isParent()}},{selector:":childless",matches:function(e){return e.isChildless()}},{selector:":child",matches:function(e){return e.isChild()}},{selector:":orphan",matches:function(e){return e.isOrphan()}},{selector:":nonorphan",matches:function(e){return e.isChild()}},{selector:":compound",matches:function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()}},{selector:":loop",matches:function(e){return e.isLoop()}},{selector:":simple",matches:function(e){return e.isSimple()}},{selector:":active",matches:function(e){return e.active()}},{selector:":inactive",matches:function(e){return!e.active()}},{selector:":backgrounding",matches:function(e){return e.backgrounding()}},{selector:":nonbackgrounding",matches:function(e){return!e.backgrounding()}}].sort((function(e,t){return function(e,t){return-1*A(e,t)}(e.selector,t.selector)})),ha=function(){for(var e,t={},n=0;n0&&l.edgeCount>0)return je("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(l.edgeCount>1)return je("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;1===l.edgeCount&&je("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},toString:function(){if(null!=this.toStringCache)return this.toStringCache;for(var e=function(e){return null==e?"":e},t=function(t){return v(t)?'"'+t+'"':e(t)},n=function(e){return" "+e+" "},r=function(r,a){var o=r.type,s=r.value;switch(o){case Xi:var l=e(s);return l.substring(0,l.length-1);case Ki:var u=r.field,c=r.operator;return"["+u+n(e(c))+t(s)+"]";case Ui:var d=r.operator,h=r.field;return"["+e(d)+h+"]";case Gi:return"["+r.field+"]";case Zi:var p=r.operator;return"[["+r.field+n(e(p))+t(s)+"]]";case $i:return s;case Qi:return"#"+s;case Ji:return"."+s;case sa:case aa:return i(r.parent,a)+n(">")+i(r.child,a);case la:case oa:return i(r.ancestor,a)+" "+i(r.descendant,a);case ua:var f=i(r.left,a),g=i(r.subject,a),v=i(r.right,a);return f+(f.length>0?" ":"")+g+v;case ca:return""}},i=function(e,t){return e.checks.reduce((function(n,i,a){return n+(t===e&&0===a?"$":"")+r(i,t)}),"")},a="",o=0;o1&&o=0&&(t=t.replace("!",""),c=!0),t.indexOf("@")>=0&&(t=t.replace("@",""),u=!0),(o||l||u)&&(i=o||s?""+e:"",a=""+n),u&&(e=i=i.toLowerCase(),n=a=a.toLowerCase()),t){case"*=":r=i.indexOf(a)>=0;break;case"$=":r=i.indexOf(a,i.length-a.length)>=0;break;case"^=":r=0===i.indexOf(a);break;case"=":r=e===n;break;case">":d=!0,r=e>n;break;case">=":d=!0,r=e>=n;break;case"<":d=!0,r=e0;){var u=i.shift();t(u),a.add(u.id()),o&&r(i,a,u)}return e}function Ba(e,t,n){if(n.isParent())for(var r=n._private.children,i=0;i1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Ba)},_a.forEachUp=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,Na)},_a.forEachUpAndDown=function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Ma(this,e,t,za)},_a.ancestors=_a.parents,(Pa=Da={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:Fi.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:Fi.removeData({field:"rscratch",triggerEvent:!1}),id:function(){var e=this[0];if(e)return e._private.data.id}}).attr=Pa.data,Pa.removeAttr=Pa.removeData;var Ia,Aa,La=Da,Oa={};function Ra(e){return function(t){if(void 0===t&&(t=!0),0!==this.length&&this.isNode()&&!this.removed()){for(var n=0,r=this[0],i=r._private.edges,a=0;at})),minIndegree:Va("indegree",(function(e,t){return et})),minOutdegree:Va("outdegree",(function(e,t){return et}))}),L(Oa,{totalDegree:function(e){for(var t=0,n=this.nodes(),r=0;r0,c=u;u&&(l=l[0]);var d=c?l.position():{x:0,y:0};return i={x:s.x-d.x,y:s.y-d.y},void 0===e?i:i[e]}for(var h=0;h0,y=g;g&&(f=f[0]);var m=y?f.position():{x:0,y:0};void 0!==t?p.position(e,t+m[e]):void 0!==i&&p.position({x:i.x+m.x,y:i.y+m.y})}}else if(!a)return;return this}}).modelPosition=Ia.point=Ia.position,Ia.modelPositions=Ia.points=Ia.positions,Ia.renderedPoint=Ia.renderedPosition,Ia.relativePoint=Ia.relativePosition;var qa,Ya,Xa=Aa;qa=Ya={},Ya.renderedBoundingBox=function(e){var t=this.boundingBox(e),n=this.cy(),r=n.zoom(),i=n.pan(),a=t.x1*r+i.x,o=t.x2*r+i.x,s=t.y1*r+i.y,l=t.y2*r+i.y;return{x1:a,x2:o,y1:s,y2:l,w:o-a,h:l-s}},Ya.dirtyCompoundBoundsCache=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();return t.styleEnabled()&&t.hasCompoundNodes()?(this.forEachUp((function(t){if(t.isParent()){var n=t._private;n.compoundBoundsClean=!1,n.bbCache=null,e||t.emitAndNotify("bounds")}})),this):this},Ya.updateCompoundBounds=function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.cy();if(!t.styleEnabled()||!t.hasCompoundNodes())return this;if(!e&&t.batching())return this;function n(e){if(e.isParent()){var t=e._private,n=e.children(),r="include"===e.pstyle("compound-sizing-wrt-labels").value,i={width:{val:e.pstyle("min-width").pfValue,left:e.pstyle("min-width-bias-left"),right:e.pstyle("min-width-bias-right")},height:{val:e.pstyle("min-height").pfValue,top:e.pstyle("min-height-bias-top"),bottom:e.pstyle("min-height-bias-bottom")}},a=n.boundingBox({includeLabels:r,includeOverlays:!1,useCache:!1}),o=t.position;0!==a.w&&0!==a.h||((a={w:e.pstyle("width").pfValue,h:e.pstyle("height").pfValue}).x1=o.x-a.w/2,a.x2=o.x+a.w/2,a.y1=o.y-a.h/2,a.y2=o.y+a.h/2);var s=i.width.left.value;"px"===i.width.left.units&&i.width.val>0&&(s=100*s/i.width.val);var l=i.width.right.value;"px"===i.width.right.units&&i.width.val>0&&(l=100*l/i.width.val);var u=i.height.top.value;"px"===i.height.top.units&&i.height.val>0&&(u=100*u/i.height.val);var c=i.height.bottom.value;"px"===i.height.bottom.units&&i.height.val>0&&(c=100*c/i.height.val);var d=y(i.width.val-a.w,s,l),h=d.biasDiff,p=d.biasComplementDiff,f=y(i.height.val-a.h,u,c),g=f.biasDiff,v=f.biasComplementDiff;t.autoPadding=function(e,t,n,r){if("%"!==n.units)return"px"===n.units?n.pfValue:0;switch(r){case"width":return e>0?n.pfValue*e:0;case"height":return t>0?n.pfValue*t:0;case"average":return e>0&&t>0?n.pfValue*(e+t)/2:0;case"min":return e>0&&t>0?e>t?n.pfValue*t:n.pfValue*e:0;case"max":return e>0&&t>0?e>t?n.pfValue*e:n.pfValue*t:0;default:return 0}}(a.w,a.h,e.pstyle("padding"),e.pstyle("padding-relative-to").value),t.autoWidth=Math.max(a.w,i.width.val),o.x=(-h+a.x1+a.x2+p)/2,t.autoHeight=Math.max(a.h,i.height.val),o.y=(-g+a.y1+a.y2+v)/2}function y(e,t,n){var r=0,i=0,a=t+n;return e>0&&a>0&&(r=t/a*e,i=n/a*e),{biasDiff:r,biasComplementDiff:i}}}for(var r=0;re.x2?r:e.x2,e.y1=ne.y2?i:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},Ka=function(e,t){return null==t?e:Ha(e,t.x1,t.y1,t.x2,t.y2)},Ga=function(e,t,n){return Ue(e,t,n)},Ua=function(e,t,n){if(!t.cy().headless()){var r,i,a=t._private,o=a.rstyle,s=o.arrowWidth/2;if("none"!==t.pstyle(n+"-arrow-shape").value){"source"===n?(r=o.srcX,i=o.srcY):"target"===n?(r=o.tgtX,i=o.tgtY):(r=o.midX,i=o.midY);var l=a.arrowBounds=a.arrowBounds||{},u=l[n]=l[n]||{};u.x1=r-s,u.y1=i-s,u.x2=r+s,u.y2=i+s,u.w=u.x2-u.x1,u.h=u.y2-u.y1,Nt(u,1),Ha(e,u.x1,u.y1,u.x2,u.y2)}}},Za=function(e,t,n){if(!t.cy().headless()){var r;r=n?n+"-":"";var i=t._private,a=i.rstyle;if(t.pstyle(r+"label").strValue){var o,s,l,u,c=t.pstyle("text-halign"),d=t.pstyle("text-valign"),h=Ga(a,"labelWidth",n),p=Ga(a,"labelHeight",n),f=Ga(a,"labelX",n),g=Ga(a,"labelY",n),v=t.pstyle(r+"text-margin-x").pfValue,y=t.pstyle(r+"text-margin-y").pfValue,m=t.isEdge(),b=t.pstyle(r+"text-rotation"),x=t.pstyle("text-outline-width").pfValue,w=t.pstyle("text-border-width").pfValue/2,E=t.pstyle("text-background-padding").pfValue,k=p,C=h,S=C/2,P=k/2;if(m)o=f-S,s=f+S,l=g-P,u=g+P;else{switch(c.value){case"left":o=f-C,s=f;break;case"center":o=f-S,s=f+S;break;case"right":o=f,s=f+C}switch(d.value){case"top":l=g-k,u=g;break;case"center":l=g-P,u=g+P;break;case"bottom":l=g,u=g+k}}var D=v-Math.max(x,w)-E-2,T=v+Math.max(x,w)+E+2,_=y-Math.max(x,w)-E-2,M=y+Math.max(x,w)+E+2;o+=D,s+=T,l+=_,u+=M;var B=n||"main",N=i.labelBounds,z=N[B]=N[B]||{};z.x1=o,z.y1=l,z.x2=s,z.y2=u,z.w=s-o,z.h=u-l,z.leftPad=D,z.rightPad=T,z.topPad=_,z.botPad=M;var I=m&&"autorotate"===b.strValue,A=null!=b.pfValue&&0!==b.pfValue;if(I||A){var L=I?Ga(i.rstyle,"labelAngle",n):b.pfValue,O=Math.cos(L),R=Math.sin(L),V=(o+s)/2,F=(l+u)/2;if(!m){switch(c.value){case"left":V=s;break;case"right":V=o}switch(d.value){case"top":F=u;break;case"bottom":F=l}}var j=function(e,t){return{x:(e-=V)*O-(t-=F)*R+V,y:e*R+t*O+F}},q=j(o,l),Y=j(o,u),X=j(s,l),W=j(s,u);o=Math.min(q.x,Y.x,X.x,W.x),s=Math.max(q.x,Y.x,X.x,W.x),l=Math.min(q.y,Y.y,X.y,W.y),u=Math.max(q.y,Y.y,X.y,W.y)}var H=B+"Rot",K=N[H]=N[H]||{};K.x1=o,K.y1=l,K.x2=s,K.y2=u,K.w=s-o,K.h=u-l,Ha(e,o,l,s,u),Ha(i.labelBounds.all,o,l,s,u)}return e}},$a=function(e,t){var n,r,i,a,o,s,l,u=e._private.cy,c=u.styleEnabled(),d=u.headless(),h=_t(),p=e._private,f=e.isNode(),g=e.isEdge(),v=p.rstyle,y=f&&c?e.pstyle("bounds-expansion").pfValue:[0],m=function(e){return"none"!==e.pstyle("display").value},b=!c||m(e)&&(!g||m(e.source())&&m(e.target()));if(b){var x=0;c&&t.includeOverlays&&0!==e.pstyle("overlay-opacity").value&&(x=e.pstyle("overlay-padding").value);var w=0;c&&t.includeUnderlays&&0!==e.pstyle("underlay-opacity").value&&(w=e.pstyle("underlay-padding").value);var E=Math.max(x,w),k=0;if(c&&(k=e.pstyle("width").pfValue/2),f&&t.includeNodes){var C=e.position();o=C.x,s=C.y;var S=e.outerWidth()/2,P=e.outerHeight()/2;Ha(h,n=o-S,i=s-P,r=o+S,a=s+P),c&&t.includeOutlines&&function(e,t){if(!t.cy().headless()){var n,r,i,a=t.pstyle("outline-opacity").value,o=t.pstyle("outline-width").value;if(a>0&&o>0){var s=t.pstyle("outline-offset").value,l=t.pstyle("shape").value,u=o+s,c=(e.w+2*u)/e.w,d=(e.h+2*u)/e.h,h=0;["diamond","pentagon","round-triangle"].includes(l)?(c=(e.w+2.4*u)/e.w,h=-u/3.6):["concave-hexagon","rhomboid","right-rhomboid"].includes(l)?c=(e.w+2.4*u)/e.w:"star"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.6*u)/e.h,h=-u/3.8):"triangle"===l?(c=(e.w+2.8*u)/e.w,d=(e.h+2.4*u)/e.h,h=-u/1.4):"vee"===l&&(c=(e.w+4.4*u)/e.w,d=(e.h+3.8*u)/e.h,h=.5*-u);var p=e.h*d-e.h,f=e.w*c-e.w;if(zt(e,[Math.ceil(p/2),Math.ceil(f/2)]),0!==h){var g=(r=0,i=h,{x1:(n=e).x1+r,x2:n.x2+r,y1:n.y1+i,y2:n.y2+i,w:n.w,h:n.h});Mt(e,g)}}}}(h,e)}else if(g&&t.includeEdges)if(c&&!d){var D=e.pstyle("curve-style").strValue;if(n=Math.min(v.srcX,v.midX,v.tgtX),r=Math.max(v.srcX,v.midX,v.tgtX),i=Math.min(v.srcY,v.midY,v.tgtY),a=Math.max(v.srcY,v.midY,v.tgtY),Ha(h,n-=k,i-=k,r+=k,a+=k),"haystack"===D){var T=v.haystackPts;if(T&&2===T.length){if(n=T[0].x,i=T[0].y,n>(r=T[1].x)){var _=n;n=r,r=_}if(i>(a=T[1].y)){var M=i;i=a,a=M}Ha(h,n-k,i-k,r+k,a+k)}}else if("bezier"===D||"unbundled-bezier"===D||D.endsWith("segments")||D.endsWith("taxi")){var B;switch(D){case"bezier":case"unbundled-bezier":B=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":B=v.linePts}if(null!=B)for(var N=0;N(r=A.x)){var L=n;n=r,r=L}if((i=I.y)>(a=A.y)){var O=i;i=a,a=O}Ha(h,n-=k,i-=k,r+=k,a+=k)}if(c&&t.includeEdges&&g&&(Ua(h,e,"mid-source"),Ua(h,e,"mid-target"),Ua(h,e,"source"),Ua(h,e,"target")),c)if("yes"===e.pstyle("ghost").value){var R=e.pstyle("ghost-offset-x").pfValue,V=e.pstyle("ghost-offset-y").pfValue;Ha(h,h.x1+R,h.y1+V,h.x2+R,h.y2+V)}var F=p.bodyBounds=p.bodyBounds||{};It(F,h),zt(F,y),Nt(F,1),c&&(n=h.x1,r=h.x2,i=h.y1,a=h.y2,Ha(h,n-E,i-E,r+E,a+E));var j=p.overlayBounds=p.overlayBounds||{};It(j,h),zt(j,y),Nt(j,1);var q=p.labelBounds=p.labelBounds||{};null!=q.all?((l=q.all).x1=1/0,l.y1=1/0,l.x2=-1/0,l.y2=-1/0,l.w=0,l.h=0):q.all=_t(),c&&t.includeLabels&&(t.includeMainLabels&&Za(h,e,null),g&&(t.includeSourceLabels&&Za(h,e,"source"),t.includeTargetLabels&&Za(h,e,"target")))}return h.x1=Wa(h.x1),h.y1=Wa(h.y1),h.x2=Wa(h.x2),h.y2=Wa(h.y2),h.w=Wa(h.x2-h.x1),h.h=Wa(h.y2-h.y1),h.w>0&&h.h>0&&b&&(zt(h,y),Nt(h,1)),h},Qa=function(e){var t=0,n=function(e){return(e?1:0)<0&&void 0!==arguments[0]?arguments[0]:bo,t=arguments.length>1?arguments[1]:void 0,n=0;n=0;s--)o(s);return this},wo.removeAllListeners=function(){return this.removeListener("*")},wo.emit=wo.trigger=function(e,t,n){var r=this.listeners,i=r.length;return this.emitting++,m(t)||(t=[t]),Co(this,(function(e,a){null!=n&&(r=[{event:a.event,type:a.type,namespace:a.namespace,callback:n}],i=r.length);for(var o=function(n){var i=r[n];if(i.type===a.type&&(!i.namespace||i.namespace===a.namespace||".*"===i.namespace)&&e.eventMatches(e.context,i,a)){var o=[a];null!=t&&function(e,t){for(var n=0;n1&&!r){var i=this.length-1,a=this[i],o=a._private.data.id;this[i]=void 0,this[e]=a,n.set(o,{ele:a,index:e})}return this.length--,this},unmergeOne:function(e){e=e[0];var t=this._private,n=e._private.data.id,r=t.map.get(n);if(!r)return this;var i=r.index;return this.unmergeAt(i),this},unmerge:function(e){var t=this._private.cy;if(!e)return this;if(e&&v(e)){var n=e;e=t.mutableElements().filter(n)}for(var r=0;r=0;t--){e(this[t])&&this.unmergeAt(t)}return this},map:function(e,t){for(var n=[],r=0;rr&&(r=o,n=a)}return{value:r,ele:n}},min:function(e,t){for(var n,r=1/0,i=0;i=0&&i1&&void 0!==arguments[1])||arguments[1],n=this[0],r=n.cy();if(r.styleEnabled()&&n){this.cleanStyle();var i=n._private.style[e];return null!=i?i:t?r.style().getDefaultProperty(e):null}},numericStyle:function(e){var t=this[0];if(t.cy().styleEnabled()&&t){var n=t.pstyle(e);return void 0!==n.pfValue?n.pfValue:n.value}},numericStyleUnits:function(e){var t=this[0];if(t.cy().styleEnabled())return t?t.pstyle(e).units:void 0},renderedStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=this[0];return n?t.style().getRenderedStyle(n,e):void 0},style:function(e,t){var n=this.cy();if(!n.styleEnabled())return this;var r=n.style();if(b(e)){var i=e;r.applyBypass(this,i,!1),this.emitAndNotify("style")}else if(v(e)){if(void 0===t){var a=this[0];return a?r.getStylePropertyValue(a,e):void 0}r.applyBypass(this,e,t,!1),this.emitAndNotify("style")}else if(void 0===e){var o=this[0];return o?r.getRawStyle(o):void 0}return this},removeStyle:function(e){var t=this.cy();if(!t.styleEnabled())return this;var n=t.style();if(void 0===e)for(var r=0;r0&&t.push(c[0]),t.push(s[0])}return this.spawn(t,!0).filter(e)}),"neighborhood"),closedNeighborhood:function(e){return this.neighborhood().add(this).filter(e)},openNeighborhood:function(e){return this.neighborhood(e)}}),Go.neighbourhood=Go.neighborhood,Go.closedNeighbourhood=Go.closedNeighborhood,Go.openNeighbourhood=Go.openNeighborhood,L(Go,{source:Ta((function(e){var t,n=this[0];return n&&(t=n._private.source||n.cy().collection()),t&&e?t.filter(e):t}),"source"),target:Ta((function(e){var t,n=this[0];return n&&(t=n._private.target||n.cy().collection()),t&&e?t.filter(e):t}),"target"),sources:Qo({attr:"source"}),targets:Qo({attr:"target"})}),L(Go,{edgesWith:Ta(Jo(),"edgesWith"),edgesTo:Ta(Jo({thisIsSrc:!0}),"edgesTo")}),L(Go,{connectedEdges:Ta((function(e){for(var t=[],n=0;n0);return a},component:function(){var e=this[0];return e.cy().mutableElements().components(e)[0]}}),Go.componentsOf=Go.components;var ts=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(void 0!==e){var i=new $e,a=!1;if(t){if(t.length>0&&b(t[0])&&!k(t[0])){a=!0;for(var o=[],s=new Je,l=0,u=t.length;l0&&void 0!==arguments[0])||arguments[0],r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this,a=i.cy(),o=a._private,s=[],l=[],u=0,c=i.length;u0){for(var R=e.length===i.length?i:new ts(a,e),V=0;V0&&void 0!==arguments[0])||arguments[0],t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],n=this,r=[],i={},a=n._private.cy;function o(e){for(var t=e._private.edges,n=0;n0&&(e?D.emitAndNotify("remove"):t&&D.emit("remove"));for(var T=0;T1e-4&&Math.abs(s.v)>1e-4;);return a?function(e){return u[e*(u.length-1)|0]}:c}}(),as=function(e,t,n,r){var i=function(e,t,n,r){var i=4,a=.001,o=1e-7,s=10,l=11,u=1/(l-1),c="undefined"!=typeof Float32Array;if(4!==arguments.length)return!1;for(var d=0;d<4;++d)if("number"!=typeof arguments[d]||isNaN(arguments[d])||!isFinite(arguments[d]))return!1;e=Math.min(e,1),n=Math.min(n,1),e=Math.max(e,0),n=Math.max(n,0);var h=c?new Float32Array(l):new Array(l);function p(e,t){return 1-3*t+3*e}function f(e,t){return 3*t-6*e}function g(e){return 3*e}function v(e,t,n){return((p(t,n)*e+f(t,n))*e+g(t))*e}function y(e,t,n){return 3*p(t,n)*e*e+2*f(t,n)*e+g(t)}function m(t,r){for(var a=0;a0?i=l:r=l}while(Math.abs(a)>o&&++u=a?m(t,s):0===c?s:x(t,r,r+u)}var E=!1;function k(){E=!0,e===t&&n===r||b()}var C=function(i){return E||k(),e===t&&n===r?i:0===i?0:1===i?1:v(w(i),t,r)};C.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var S="generateBezier("+[e,t,n,r]+")";return C.toString=function(){return S},C}(e,t,n,r);return function(e,t,n){return e+(t-e)*i(n)}},os={linear:function(e,t,n){return e+(t-e)*n},ease:as(.25,.1,.25,1),"ease-in":as(.42,0,1,1),"ease-out":as(0,0,.58,1),"ease-in-out":as(.42,0,.58,1),"ease-in-sine":as(.47,0,.745,.715),"ease-out-sine":as(.39,.575,.565,1),"ease-in-out-sine":as(.445,.05,.55,.95),"ease-in-quad":as(.55,.085,.68,.53),"ease-out-quad":as(.25,.46,.45,.94),"ease-in-out-quad":as(.455,.03,.515,.955),"ease-in-cubic":as(.55,.055,.675,.19),"ease-out-cubic":as(.215,.61,.355,1),"ease-in-out-cubic":as(.645,.045,.355,1),"ease-in-quart":as(.895,.03,.685,.22),"ease-out-quart":as(.165,.84,.44,1),"ease-in-out-quart":as(.77,0,.175,1),"ease-in-quint":as(.755,.05,.855,.06),"ease-out-quint":as(.23,1,.32,1),"ease-in-out-quint":as(.86,0,.07,1),"ease-in-expo":as(.95,.05,.795,.035),"ease-out-expo":as(.19,1,.22,1),"ease-in-out-expo":as(1,0,0,1),"ease-in-circ":as(.6,.04,.98,.335),"ease-out-circ":as(.075,.82,.165,1),"ease-in-out-circ":as(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return os.linear;var r=is(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":as};function ss(e,t,n,r,i){if(1===r)return n;if(t===n)return n;var a=i(t,n,r);return null==e||((e.roundValue||e.color)&&(a=Math.round(a)),void 0!==e.min&&(a=Math.max(a,e.min)),void 0!==e.max&&(a=Math.min(a,e.max))),a}function ls(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function us(e,t,n,r,i){var a=null!=i?i.type:null;n<0?n=0:n>1&&(n=1);var o=ls(e,i),s=ls(t,i);if(x(o)&&x(s))return ss(a,o,s,n,r);if(m(o)&&m(s)){for(var l=[],u=0;u0?("spring"===d&&h.push(o.duration),o.easingImpl=os[d].apply(null,h)):o.easingImpl=os[d]}var p,f=o.easingImpl;if(p=0===o.duration?1:(n-l)/o.duration,o.applying&&(p=o.progress),p<0?p=0:p>1&&(p=1),null==o.delay){var g=o.startPosition,y=o.position;if(y&&i&&!e.locked()){var m={};ds(g.x,y.x)&&(m.x=us(g.x,y.x,p,f)),ds(g.y,y.y)&&(m.y=us(g.y,y.y,p,f)),e.position(m)}var b=o.startPan,x=o.pan,w=a.pan,E=null!=x&&r;E&&(ds(b.x,x.x)&&(w.x=us(b.x,x.x,p,f)),ds(b.y,x.y)&&(w.y=us(b.y,x.y,p,f)),e.emit("pan"));var k=o.startZoom,C=o.zoom,S=null!=C&&r;S&&(ds(k,C)&&(a.zoom=Tt(a.minZoom,us(k,C,p,f),a.maxZoom)),e.emit("zoom")),(E||S)&&e.emit("viewport");var P=o.style;if(P&&P.length>0&&i){for(var D=0;D=0;t--){(0,e[t])()}e.splice(0,e.length)},c=a.length-1;c>=0;c--){var d=a[c],h=d._private;h.stopped?(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.frames)):(h.playing||h.applying)&&(h.playing&&h.applying&&(h.applying=!1),h.started||hs(0,d,e),cs(t,d,e,n),h.applying&&(h.applying=!1),u(h.frames),null!=h.step&&h.step(e),d.completed()&&(a.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.completes)),s=!0)}return n||0!==a.length||0!==o.length||r.push(t),s}for(var a=!1,o=0;o0?t.notify("draw",n):t.notify("draw")),n.unmerge(r),t.emit("step")}var fs={animate:Fi.animate(),animation:Fi.animation(),animated:Fi.animated(),clearQueue:Fi.clearQueue(),delay:Fi.delay(),delayAnimation:Fi.delayAnimation(),stop:Fi.stop(),addToAnimationPool:function(e){this.styleEnabled()&&this._private.aniEles.merge(e)},stopAnimationLoop:function(){this._private.animationsRunning=!1},startAnimationLoop:function(){var e=this;if(e._private.animationsRunning=!0,e.styleEnabled()){var t=e.renderer();t&&t.beforeRender?t.beforeRender((function(t,n){ps(n,e)}),t.beforeRenderPriorities.animations):function t(){e._private.animationsRunning&&xe((function(n){ps(n,e),t()}))}()}}},gs={qualifierCompare:function(e,t){return null==e||null==t?null==e&&null==t:e.sameText(t)},eventMatches:function(e,t,n){var r=t.qualifier;return null==r||e!==n.target&&k(n.target)&&r.matches(n.target)},addEventFields:function(e,t){t.cy=e,t.target=e},callbackContext:function(e,t,n){return null!=t.qualifier?n.target:e}},vs=function(e){return v(e)?new ka(e):e},ys={createEmitter:function(){var e=this._private;return e.emitter||(e.emitter=new xo(gs,this)),this},emitter:function(){return this._private.emitter},on:function(e,t,n){return this.emitter().on(e,vs(t),n),this},removeListener:function(e,t,n){return this.emitter().removeListener(e,vs(t),n),this},removeAllListeners:function(){return this.emitter().removeAllListeners(),this},one:function(e,t,n){return this.emitter().one(e,vs(t),n),this},once:function(e,t,n){return this.emitter().one(e,vs(t),n),this},emit:function(e,t){return this.emitter().emit(e,t),this},emitAndNotify:function(e,t){return this.emit(e),this.notify(e,t),this}};Fi.eventAliasesOn(ys);var ms={png:function(e){return e=e||{},this._private.renderer.png(e)},jpg:function(e){var t=this._private.renderer;return(e=e||{}).bg=e.bg||"#fff",t.jpg(e)}};ms.jpeg=ms.jpg;var bs={layout:function(e){if(null!=e)if(null!=e.name){var t=e.name,n=this.extension("layout",t);if(null!=n){var r;r=v(e.eles)?this.$(e.eles):null!=e.eles?e.eles:this.$();var i=new n(L({},e,{cy:this,eles:r}));return i}Ve("No such layout `"+t+"` found. Did you forget to import it and `cytoscape.use()` it?")}else Ve("A `name` must be specified to make a layout");else Ve("Layout options must be specified to make a layout")}};bs.createLayout=bs.makeLayout=bs.layout;var xs={notify:function(e,t){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var r=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();null!=t&&r.merge(t)}else if(n.notificationsEnabled){var i=this.renderer();!this.destroyed()&&i&&i.notify(e,t)}},notifications:function(e){var t=this._private;return void 0===e?t.notificationsEnabled:(t.notificationsEnabled=!!e,this)},noNotifications:function(e){this.notifications(!1),e(),this.notifications(!0)},batching:function(){return this._private.batchCount>0},startBatch:function(){var e=this._private;return null==e.batchCount&&(e.batchCount=0),0===e.batchCount&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},endBatch:function(){var e=this._private;if(0===e.batchCount)return this;if(e.batchCount--,0===e.batchCount){e.batchStyleEles.updateStyle();var t=this.renderer();Object.keys(e.batchNotifications).forEach((function(n){var r=e.batchNotifications[n];r.empty()?t.notify(n):t.notify(n,r)}))}return this},batch:function(e){return this.startBatch(),e(),this.endBatch(),this},batchData:function(e){var t=this;return this.batch((function(){for(var n=Object.keys(e),r=0;r0;)e.removeChild(e.childNodes[0]);this._private.renderer=null,this.mutableElements().forEach((function(e){var t=e._private;t.rscratch={},t.rstyle={},t.animation.current=[],t.animation.queue=[]}))},onRender:function(e){return this.on("render",e)},offRender:function(e){return this.off("render",e)}};Es.invalidateDimensions=Es.resize;var ks={collection:function(e,t){return v(e)?this.$(e):E(e)?e.collection():m(e)?(t||(t={}),new ts(this,e,t.unique,t.removed)):new ts(this)},nodes:function(e){var t=this.$((function(e){return e.isNode()}));return e?t.filter(e):t},edges:function(e){var t=this.$((function(e){return e.isEdge()}));return e?t.filter(e):t},$:function(e){var t=this._private.elements;return e?t.filter(e):t.spawnSelf()},mutableElements:function(){return this._private.elements}};ks.elements=ks.filter=ks.$;var Cs={};Cs.apply=function(e){for(var t=this._private.cy.collection(),n=0;n0;if(d||c&&h){var p=void 0;d&&h||d?p=l.properties:h&&(p=l.mappedProperties);for(var f=0;f1&&(g=1),s.color){var w=i.valueMin[0],E=i.valueMax[0],k=i.valueMin[1],C=i.valueMax[1],S=i.valueMin[2],P=i.valueMax[2],D=null==i.valueMin[3]?1:i.valueMin[3],T=null==i.valueMax[3]?1:i.valueMax[3],_=[Math.round(w+(E-w)*g),Math.round(k+(C-k)*g),Math.round(S+(P-S)*g),Math.round(D+(T-D)*g)];n={bypass:i.bypass,name:i.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else{if(!s.number)return!1;var M=i.valueMin+(i.valueMax-i.valueMin)*g;n=this.parse(i.name,M,i.bypass,"mapping")}if(!n)return f(),!1;n.mapping=i,i=n;break;case o.data:for(var B=i.field.split("."),N=d.data,z=0;z0&&a>0){for(var s={},l=!1,u=0;u0?e.delayAnimation(o).play().promise().then(t):t()})).then((function(){return e.animation({style:s,duration:a,easing:e.pstyle("transition-timing-function").value,queue:!1}).play().promise()})).then((function(){n.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1}))}else r.transitioning&&(this.removeBypasses(e,i),e.emitAndNotify("style"),r.transitioning=!1)},Cs.checkTrigger=function(e,t,n,r,i,a){var o=this.properties[t],s=i(o);null!=s&&s(n,r)&&a(o)},Cs.checkZOrderTrigger=function(e,t,n,r){var i=this;this.checkTrigger(e,t,n,r,(function(e){return e.triggersZOrder}),(function(){i._private.cy.notify("zorder",e)}))},Cs.checkBoundsTrigger=function(e,t,n,r){this.checkTrigger(e,t,n,r,(function(e){return e.triggersBounds}),(function(i){e.dirtyCompoundBoundsCache(),e.dirtyBoundingBoxCache(),!i.triggersBoundsOfParallelBeziers||"curve-style"!==t||"bezier"!==n&&"bezier"!==r||e.parallelEdges().forEach((function(e){e.dirtyBoundingBoxCache()})),!i.triggersBoundsOfConnectedEdges||"display"!==t||"none"!==n&&"none"!==r||e.connectedEdges().forEach((function(e){e.dirtyBoundingBoxCache()}))}))},Cs.checkTriggers=function(e,t,n,r){e.dirtyStyleCache(),this.checkZOrderTrigger(e,t,n,r),this.checkBoundsTrigger(e,t,n,r)};var Ss={applyBypass:function(e,t,n,r){var i=[];if("*"===t||"**"===t){if(void 0!==n)for(var a=0;at.length?i.substr(t.length):""}function o(){n=n.length>r.length?n.substr(r.length):""}for(i=i.replace(/[/][*](\s|.)+?[*][/]/g,"");;){if(i.match(/^\s*$/))break;var s=i.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!s){je("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+i);break}t=s[0];var l=s[1];if("core"!==l)if(new ka(l).invalid){je("Skipping parsing of block: Invalid selector found in string stylesheet: "+l),a();continue}var u=s[2],c=!1;n=u;for(var d=[];;){if(n.match(/^\s*$/))break;var h=n.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!h){je("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+u),c=!0;break}r=h[0];var p=h[1],f=h[2];if(this.properties[p])this.parse(p,f)?(d.push({name:p,val:f}),o()):(je("Skipping property: Invalid property definition in: "+r),o());else je("Skipping property: Invalid property name in: "+r),o()}if(c){a();break}this.selector(l);for(var g=0;g=7&&"d"===t[0]&&(l=new RegExp(o.data.regex).exec(t))){if(n)return!1;var d=o.data;return{name:e,value:l,strValue:""+t,mapped:d,field:l[1],bypass:n}}if(t.length>=10&&"m"===t[0]&&(u=new RegExp(o.mapData.regex).exec(t))){if(n)return!1;if(c.multiple)return!1;var h=o.mapData;if(!c.color&&!c.number)return!1;var p=this.parse(e,u[4]);if(!p||p.mapped)return!1;var f=this.parse(e,u[5]);if(!f||f.mapped)return!1;if(p.pfValue===f.pfValue||p.strValue===f.strValue)return je("`"+e+": "+t+"` is not a valid mapper because the output range is zero; converting to `"+e+": "+p.strValue+"`"),this.parse(e,p.strValue);if(c.color){var g=p.value,b=f.value;if(!(g[0]!==b[0]||g[1]!==b[1]||g[2]!==b[2]||g[3]!==b[3]&&(null!=g[3]&&1!==g[3]||null!=b[3]&&1!==b[3])))return!1}return{name:e,value:u,strValue:""+t,mapped:h,field:u[1],fieldMin:parseFloat(u[2]),fieldMax:parseFloat(u[3]),valueMin:p.value,valueMax:f.value,bypass:n}}}if(c.multiple&&"multiple"!==r){var w;if(w=s?t.split(/\s+/):m(t)?t:[t],c.evenMultiple&&w.length%2!=0)return null;for(var E=[],k=[],C=[],S="",P=!1,D=0;D0?" ":"")+T.strValue}return c.validate&&!c.validate(E,k)?null:c.singleEnum&&P?1===E.length&&v(E[0])?{name:e,value:E[0],strValue:E[0],bypass:n}:null:{name:e,value:E,pfValue:C,strValue:S,bypass:n,units:k}}var _,B,N=function(){for(var r=0;rc.max||c.strictMax&&t===c.max))return null;var V={name:e,value:t,strValue:""+t+(z||""),units:z,bypass:n};return c.unitless||"px"!==z&&"em"!==z?V.pfValue=t:V.pfValue="px"!==z&&z?this.getEmSizeInPixels()*t:t,"ms"!==z&&"s"!==z||(V.pfValue="ms"===z?t:1e3*t),"deg"!==z&&"rad"!==z||(V.pfValue="rad"===z?t:(_=t,Math.PI*_/180)),"%"===z&&(V.pfValue=t/100),V}if(c.propList){var F=[],j=""+t;if("none"===j);else{for(var q=j.split(/\s*,\s*|\s+/),Y=0;Y0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0)return{zoom:o=(o=(o=Math.min((s-2*t)/n.w,(l-2*t)/n.h))>this._private.maxZoom?this._private.maxZoom:o)=n.minZoom&&(n.maxZoom=t),this},minZoom:function(e){return void 0===e?this._private.minZoom:this.zoomRange({min:e})},maxZoom:function(e){return void 0===e?this._private.maxZoom:this.zoomRange({max:e})},getZoomedViewport:function(e){var t,n,r=this._private,i=r.pan,a=r.zoom,o=!1;if(r.zoomingEnabled||(o=!0),x(e)?n=e:b(e)&&(n=e.level,null!=e.position?t=yt(e.position,a,i):null!=e.renderedPosition&&(t=e.renderedPosition),null==t||r.panningEnabled||(o=!0)),n=(n=n>r.maxZoom?r.maxZoom:n)t.maxZoom||!t.zoomingEnabled?a=!0:(t.zoom=s,i.push("zoom"))}if(r&&(!a||!e.cancelOnFailedZoom)&&t.panningEnabled){var l=e.pan;x(l.x)&&(t.pan.x=l.x,o=!1),x(l.y)&&(t.pan.y=l.y,o=!1),o||i.push("pan")}return i.length>0&&(i.push("viewport"),this.emit(i.join(" ")),this.notify("viewport")),this},center:function(e){var t=this.getCenterPan(e);return t&&(this._private.pan=t,this.emit("pan viewport"),this.notify("viewport")),this},getCenterPan:function(e,t){if(this._private.panningEnabled){if(v(e)){var n=e;e=this.mutableElements().filter(n)}else E(e)||(e=this.mutableElements());if(0!==e.length){var r=e.boundingBox(),i=this.width(),a=this.height();return{x:(i-(t=void 0===t?this._private.zoom:t)*(r.x1+r.x2))/2,y:(a-t*(r.y1+r.y2))/2}}}},reset:function(){return this._private.panningEnabled&&this._private.zoomingEnabled?(this.viewport({pan:{x:0,y:0},zoom:1}),this):this},invalidateSize:function(){this._private.sizeCache=null},size:function(){var e,t,n=this._private,r=n.container,i=this;return n.sizeCache=n.sizeCache||(r?(e=i.window().getComputedStyle(r),t=function(t){return parseFloat(e.getPropertyValue(t))},{width:r.clientWidth-t("padding-left")-t("padding-right"),height:r.clientHeight-t("padding-top")-t("padding-bottom")}):{width:1,height:1})},width:function(){return this.size().width},height:function(){return this.size().height},extent:function(){var e=this._private.pan,t=this._private.zoom,n=this.renderedExtent(),r={x1:(n.x1-e.x)/t,x2:(n.x2-e.x)/t,y1:(n.y1-e.y)/t,y2:(n.y2-e.y)/t};return r.w=r.x2-r.x1,r.h=r.y2-r.y1,r},renderedExtent:function(){var e=this.width(),t=this.height();return{x1:0,y1:0,x2:e,y2:t,w:e,h:t}},multiClickDebounceTime:function(e){return e?(this._private.multiClickDebounceTime=e,this):this._private.multiClickDebounceTime}};As.centre=As.center,As.autolockNodes=As.autolock,As.autoungrabifyNodes=As.autoungrabify;var Ls={data:Fi.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:Fi.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:Fi.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:Fi.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};Ls.attr=Ls.data,Ls.removeAttr=Ls.removeData;var Os=function(e){var t=this,n=(e=L({},e)).container;n&&!w(n)&&w(n[0])&&(n=n[0]);var r=n?n._cyreg:null;(r=r||{})&&r.cy&&(r.cy.destroy(),r={});var i=r.readies=r.readies||[];n&&(n._cyreg=r),r.cy=t;var a=void 0!==u&&void 0!==n&&!e.headless,o=e;o.layout=L({name:a?"grid":"null"},o.layout),o.renderer=L({name:a?"canvas":"null"},o.renderer);var s=function(e,t,n){return void 0!==t?t:void 0!==n?n:e},l=this._private={container:n,ready:!1,options:o,elements:new ts(this),listeners:[],aniEles:new ts(this),data:o.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:s(!0,o.zoomingEnabled),userZoomingEnabled:s(!0,o.userZoomingEnabled),panningEnabled:s(!0,o.panningEnabled),userPanningEnabled:s(!0,o.userPanningEnabled),boxSelectionEnabled:s(!0,o.boxSelectionEnabled),autolock:s(!1,o.autolock,o.autolockNodes),autoungrabify:s(!1,o.autoungrabify,o.autoungrabifyNodes),autounselectify:s(!1,o.autounselectify),styleEnabled:void 0===o.styleEnabled?a:o.styleEnabled,zoom:x(o.zoom)?o.zoom:1,pan:{x:b(o.pan)&&x(o.pan.x)?o.pan.x:0,y:b(o.pan)&&x(o.pan.y)?o.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:s(250,o.multiClickDebounceTime)};this.createEmitter(),this.selectionType(o.selectionType),this.zoomRange({min:o.minZoom,max:o.maxZoom});l.styleEnabled&&t.setStyle([]);var c=L({},o,o.renderer);t.initRenderer(c);!function(e,t){if(e.some(T))return vr.all(e).then(t);t(e)}([o.style,o.elements],(function(e){var n=e[0],a=e[1];l.styleEnabled&&t.style().append(n),function(e,n,r){t.notifications(!1);var i=t.mutableElements();i.length>0&&i.remove(),null!=e&&(b(e)||m(e))&&t.add(e),t.one("layoutready",(function(e){t.notifications(!0),t.emit(e),t.one("load",n),t.emitAndNotify("load")})).one("layoutstop",(function(){t.one("done",r),t.emit("done")}));var a=L({},t._private.options.layout);a.eles=t.elements(),t.layout(a).run()}(a,(function(){t.startAnimationLoop(),l.ready=!0,y(o.ready)&&t.on("ready",o.ready);for(var e=0;e0,u=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(E(n.roots))e=n.roots;else if(m(n.roots)){for(var c=[],d=0;d0;){var N=_.shift(),z=T(N,M);if(z)N.outgoers().filter((function(e){return e.isNode()&&i.has(e)})).forEach(B);else if(null===z){je("Detected double maximal shift for node `"+N.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}D();var I=0;if(n.avoidOverlap)for(var L=0;L0&&b[0].length<=3?l/2:0),d=2*Math.PI/b[r].length*i;return 0===r&&1===b[0].length&&(c=1),{x:G+c*Math.cos(d),y:U+c*Math.sin(d)}}return{x:G+(i+1-(a+1)/2)*o,y:(r+1)*s}})),this};var Xs={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Ws(e){this.options=L({},Xs,e)}Ws.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,a=r.nodes().not(":parent");t.sort&&(a=a.sort(t.sort));for(var o,s=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l=s.x1+s.w/2,u=s.y1+s.h/2,c=(void 0===t.sweep?2*Math.PI-2*Math.PI/a.length:t.sweep)/Math.max(1,a.length-1),d=0,h=0;h1&&t.avoidOverlap){d*=1.75;var v=Math.cos(c)-Math.cos(0),y=Math.sin(c)-Math.sin(0),m=Math.sqrt(d*d/(v*v+y*y));o=Math.max(m,o)}return r.nodes().layoutPositions(this,t,(function(e,n){var r=t.startAngle+n*c*(i?1:-1),a=o*Math.cos(r),s=o*Math.sin(r);return{x:l+a,y:u+s}})),this};var Hs,Ks={fit:!0,padding:30,startAngle:1.5*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:function(e){return e.degree()},levelWidth:function(e){return e.maxDegree()/4},animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function Gs(e){this.options=L({},Ks,e)}Gs.prototype.run=function(){for(var e=this.options,t=e,n=void 0!==t.counterclockwise?!t.counterclockwise:t.clockwise,r=e.cy,i=t.eles,a=i.nodes().not(":parent"),o=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),s=o.x1+o.w/2,l=o.y1+o.h/2,u=[],c=0,d=0;d0)Math.abs(m[0].value-x.value)>=v&&(m=[],y.push(m));m.push(x)}var w=c+t.minNodeSpacing;if(!t.avoidOverlap){var E=y.length>0&&y[0].length>1,k=(Math.min(o.w,o.h)/2-w)/(y.length+E?1:0);w=Math.min(w,k)}for(var C=0,S=0;S1&&t.avoidOverlap){var _=Math.cos(T)-Math.cos(0),M=Math.sin(T)-Math.sin(0),B=Math.sqrt(w*w/(_*_+M*M));C=Math.max(B,C)}P.r=C,C+=w}if(t.equidistant){for(var N=0,z=0,I=0;I=e.numIter)&&(rl(r,e),r.temperature=r.temperature*e.coolingFactor,!(r.temperature=e.animationThreshold&&a(),xe(t)):(gl(r,e),s())}()}else{for(;u;)u=o(l),l++;gl(r,e),s()}return this},Zs.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this},Zs.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};var $s=function(e,t,n){for(var r=n.eles.edges(),i=n.eles.nodes(),a=_t(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),o={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:i.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:r.size(),temperature:n.initialTemp,clientWidth:a.w,clientHeight:a.h,boundingBox:a},s=n.eles.components(),l={},u=0;u0){o.graphSet.push(E);for(u=0;ur.count?0:r.graph},Js=function e(t,n,r,i){var a=i.graphSet[r];if(-10)var s=(u=r.nodeOverlap*o)*i/(g=Math.sqrt(i*i+a*a)),l=u*a/g;else{var u,c=ll(e,i,a),d=ll(t,-1*i,-1*a),h=d.x-c.x,p=d.y-c.y,f=h*h+p*p,g=Math.sqrt(f);s=(u=(e.nodeRepulsion+t.nodeRepulsion)/f)*h/g,l=u*p/g}e.isLocked||(e.offsetX-=s,e.offsetY-=l),t.isLocked||(t.offsetX+=s,t.offsetY+=l)}},sl=function(e,t,n,r){if(n>0)var i=e.maxX-t.minX;else i=t.maxX-e.minX;if(r>0)var a=e.maxY-t.minY;else a=t.maxY-e.minY;return i>=0&&a>=0?Math.sqrt(i*i+a*a):0},ll=function(e,t,n){var r=e.positionX,i=e.positionY,a=e.height||1,o=e.width||1,s=n/t,l=a/o,u={};return 0===t&&0n?(u.x=r,u.y=i+a/2,u):0t&&-1*l<=s&&s<=l?(u.x=r-o/2,u.y=i-o*n/2/t,u):0=l)?(u.x=r+a*t/2/n,u.y=i+a/2,u):0>n&&(s<=-1*l||s>=l)?(u.x=r-a*t/2/n,u.y=i-a/2,u):u},ul=function(e,t){for(var n=0;n1){var f=t.gravity*d/p,g=t.gravity*h/p;c.offsetX+=f,c.offsetY+=g}}}}},dl=function(e,t){var n=[],r=0,i=-1;for(n.push.apply(n,e.graphSet[0]),i+=e.graphSet[0].length;r<=i;){var a=n[r++],o=e.idToIndex[a],s=e.layoutNodes[o],l=s.children;if(0n)var i={x:n*e/r,y:n*t/r};else i={x:e,y:t};return i},fl=function e(t,n){var r=t.parentId;if(null!=r){var i=n.layoutNodes[n.idToIndex[r]],a=!1;return(null==i.maxX||t.maxX+i.padRight>i.maxX)&&(i.maxX=t.maxX+i.padRight,a=!0),(null==i.minX||t.minX-i.padLefti.maxY)&&(i.maxY=t.maxY+i.padBottom,a=!0),(null==i.minY||t.minY-i.padTopf&&(d+=p+t.componentSpacing,c=0,h=0,p=0)}}},vl={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:function(e){},sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:function(e,t){return!0},ready:void 0,stop:void 0,transform:function(e,t){return t}};function yl(e){this.options=L({},vl,e)}yl.prototype.run=function(){var e=this.options,t=e,n=e.cy,r=t.eles,i=r.nodes().not(":parent");t.sort&&(i=i.sort(t.sort));var a=_t(t.boundingBox?t.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()});if(0===a.h||0===a.w)r.nodes().layoutPositions(this,t,(function(e){return{x:a.x1,y:a.y1}}));else{var o=i.size(),s=Math.sqrt(o*a.h/a.w),l=Math.round(s),u=Math.round(a.w/a.h*s),c=function(e){if(null==e)return Math.min(l,u);Math.min(l,u)==l?l=e:u=e},d=function(e){if(null==e)return Math.max(l,u);Math.max(l,u)==l?l=e:u=e},h=t.rows,p=null!=t.cols?t.cols:t.columns;if(null!=h&&null!=p)l=h,u=p;else if(null!=h&&null==p)l=h,u=Math.ceil(o/l);else if(null==h&&null!=p)u=p,l=Math.ceil(o/u);else if(u*l>o){var f=c(),g=d();(f-1)*g>=o?c(f-1):(g-1)*f>=o&&d(g-1)}else for(;u*l=o?d(y+1):c(v+1)}var m=a.w/u,b=a.h/l;if(t.condense&&(m=0,b=0),t.avoidOverlap)for(var x=0;x=u&&(B=0,M++)},z={},I=0;I(r=qt(e,t,x[w],x[w+1],x[w+2],x[w+3])))return v(n,r),!0}else if("bezier"===a.edgeType||"multibezier"===a.edgeType||"self"===a.edgeType||"compound"===a.edgeType)for(x=a.allpts,w=0;w+5(r=jt(e,t,x[w],x[w+1],x[w+2],x[w+3],x[w+4],x[w+5])))return v(n,r),!0;m=m||i.source,b=b||i.target;var E=o.getArrowWidth(l,c),k=[{name:"source",x:a.arrowStartX,y:a.arrowStartY,angle:a.srcArrowAngle},{name:"target",x:a.arrowEndX,y:a.arrowEndY,angle:a.tgtArrowAngle},{name:"mid-source",x:a.midX,y:a.midY,angle:a.midsrcArrowAngle},{name:"mid-target",x:a.midX,y:a.midY,angle:a.midtgtArrowAngle}];for(w=0;w0&&(y(m),y(b))}function b(e,t,n){return Ue(e,t,n)}function x(n,r){var i,a=n._private,o=f;i=r?r+"-":"",n.boundingBox();var s=a.labelBounds[r||"main"],l=n.pstyle(i+"label").value;if("yes"===n.pstyle("text-events").strValue&&l){var u=b(a.rscratch,"labelX",r),c=b(a.rscratch,"labelY",r),d=b(a.rscratch,"labelAngle",r),h=n.pstyle(i+"text-margin-x").pfValue,p=n.pstyle(i+"text-margin-y").pfValue,g=s.x1-o-h,y=s.x2+o-h,m=s.y1-o-p,x=s.y2+o-p;if(d){var w=Math.cos(d),E=Math.sin(d),k=function(e,t){return{x:(e-=u)*w-(t-=c)*E+u,y:e*E+t*w+c}},C=k(g,m),S=k(g,x),P=k(y,m),D=k(y,x),T=[C.x+h,C.y+p,P.x+h,P.y+p,D.x+h,D.y+p,S.x+h,S.y+p];if(Yt(e,t,T))return v(n),!0}else if(Lt(s,e,t))return v(n),!0}}n&&(l=l.interactive);for(var w=l.length-1;w>=0;w--){var E=l[w];E.isNode()?y(E)||x(E):m(E)||x(E)||x(E,"source")||x(E,"target")}return u},getAllInBox:function(e,t,n,r){for(var i,a,o=this.getCachedZSortedEles().interactive,s=[],l=Math.min(e,n),u=Math.max(e,n),c=Math.min(t,r),d=Math.max(t,r),h=_t({x1:e=l,y1:t=c,x2:n=u,y2:r=d}),p=0;p0?-(Math.PI-a.ang):Math.PI+a.ang),Zl(t,n,Ul),zl=Gl.nx*Ul.ny-Gl.ny*Ul.nx,Il=Gl.nx*Ul.nx-Gl.ny*-Ul.ny,Ol=Math.asin(Math.max(-1,Math.min(1,zl))),Math.abs(Ol)<1e-6)return Bl=t.x,Nl=t.y,void(Vl=jl=0);Al=1,Ll=!1,Il<0?Ol<0?Ol=Math.PI+Ol:(Ol=Math.PI-Ol,Al=-1,Ll=!0):Ol>0&&(Al=-1,Ll=!0),jl=void 0!==t.radius?t.radius:r,Rl=Ol/2,ql=Math.min(Gl.len/2,Ul.len/2),i?(Fl=Math.abs(Math.cos(Rl)*jl/Math.sin(Rl)))>ql?(Fl=ql,Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))):Vl=jl:(Fl=Math.min(ql,jl),Vl=Math.abs(Fl*Math.sin(Rl)/Math.cos(Rl))),Wl=t.x+Ul.nx*Fl,Hl=t.y+Ul.ny*Fl,Bl=Wl-Ul.ny*Vl*Al,Nl=Hl+Ul.nx*Vl*Al,Yl=t.x+Gl.nx*Fl,Xl=t.y+Gl.ny*Fl,Kl=t};function Ql(e,t){0===t.radius?e.lineTo(t.cx,t.cy):e.arc(t.cx,t.cy,t.radius,t.startAngle,t.endAngle,t.counterClockwise)}function Jl(e,t,n,r){var i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4];return 0===r||0===t.radius?{cx:t.x,cy:t.y,radius:0,startX:t.x,startY:t.y,stopX:t.x,stopY:t.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:($l(e,t,n,r,i),{cx:Bl,cy:Nl,radius:Vl,startX:Yl,startY:Xl,stopX:Wl,stopY:Hl,startAngle:Gl.ang+Math.PI/2*Al,endAngle:Ul.ang-Math.PI/2*Al,counterClockwise:Ll})}var eu={};function tu(e){var t=[];if(null!=e){for(var n=0;n0?Math.max(e-t,0):Math.min(e+t,0)},w=x(m,v),E=x(b,y),k=!1;"auto"===c?u=Math.abs(w)>Math.abs(E)?"horizontal":"vertical":"upward"===c||"downward"===c?(u="vertical",k=!0):"leftward"!==c&&"rightward"!==c||(u="horizontal",k=!0);var C,S="vertical"===u,P=S?E:w,D=S?b:m,T=Et(D),_=!1;(k&&(h||f)||!("downward"===c&&D<0||"upward"===c&&D>0||"leftward"===c&&D>0||"rightward"===c&&D<0)||(P=(T*=-1)*Math.abs(P),_=!0),h)?C=(p<0?1+p:p)*P:C=(p<0?P:0)+p*T;var M=function(e){return Math.abs(e)=Math.abs(P)},B=M(C),N=M(Math.abs(P)-Math.abs(C));if((B||N)&&!_)if(S){var z=Math.abs(D)<=a/2,I=Math.abs(m)<=o/2;if(z){var A=(r.x1+r.x2)/2,L=r.y1,O=r.y2;n.segpts=[A,L,A,O]}else if(I){var R=(r.y1+r.y2)/2,V=r.x1,F=r.x2;n.segpts=[V,R,F,R]}else n.segpts=[r.x1,r.y2]}else{var j=Math.abs(D)<=i/2,q=Math.abs(b)<=s/2;if(j){var Y=(r.y1+r.y2)/2,X=r.x1,W=r.x2;n.segpts=[X,Y,W,Y]}else if(q){var H=(r.x1+r.x2)/2,K=r.y1,G=r.y2;n.segpts=[H,K,H,G]}else n.segpts=[r.x2,r.y1]}else if(S){var U=r.y1+C+(l?a/2*T:0),Z=r.x1,$=r.x2;n.segpts=[Z,U,$,U]}else{var Q=r.x1+C+(l?i/2*T:0),J=r.y1,ee=r.y2;n.segpts=[Q,J,Q,ee]}if(n.isRound){var te=e.pstyle("taxi-radius").value,ne="arc-radius"===e.pstyle("radius-type").value[0];n.radii=new Array(n.segpts.length/2).fill(te),n.isArcRadius=new Array(n.segpts.length/2).fill(ne)}},eu.tryToCorrectInvalidPoints=function(e,t){var n=e._private.rscratch;if("bezier"===n.edgeType){var r=t.srcPos,i=t.tgtPos,a=t.srcW,o=t.srcH,s=t.tgtW,l=t.tgtH,u=t.srcShape,c=t.tgtShape,d=t.srcCornerRadius,h=t.tgtCornerRadius,p=t.srcRs,f=t.tgtRs,g=!x(n.startX)||!x(n.startY),v=!x(n.arrowStartX)||!x(n.arrowStartY),y=!x(n.endX)||!x(n.endY),m=!x(n.arrowEndX)||!x(n.arrowEndY),b=3*(this.getArrowWidth(e.pstyle("width").pfValue,e.pstyle("arrow-scale").value)*this.arrowShapeWidth),w=kt({x:n.ctrlpts[0],y:n.ctrlpts[1]},{x:n.startX,y:n.startY}),E=wh.poolIndex()){var p=d;d=h,h=p}var f=s.srcPos=d.position(),g=s.tgtPos=h.position(),v=s.srcW=d.outerWidth(),y=s.srcH=d.outerHeight(),m=s.tgtW=h.outerWidth(),b=s.tgtH=h.outerHeight(),w=s.srcShape=n.nodeShapes[t.getNodeShape(d)],E=s.tgtShape=n.nodeShapes[t.getNodeShape(h)],k=s.srcCornerRadius="auto"===d.pstyle("corner-radius").value?"auto":d.pstyle("corner-radius").pfValue,C=s.tgtCornerRadius="auto"===h.pstyle("corner-radius").value?"auto":h.pstyle("corner-radius").pfValue,S=s.tgtRs=h._private.rscratch,P=s.srcRs=d._private.rscratch;s.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var D=0;D0){var H=u,K=Ct(H,bt(t)),G=Ct(H,bt(W)),U=K;if(G2)Ct(H,{x:W[2],y:W[3]})0){var le=c,ue=Ct(le,bt(t)),ce=Ct(le,bt(se)),de=ue;if(ce2)Ct(le,{x:se[2],y:se[3]})=c||b){d={cp:v,segment:m};break}}if(d)break}var x=d.cp,w=d.segment,E=(c-p)/w.length,k=w.t1-w.t0,C=u?w.t0+k*E:w.t1-k*E;C=Tt(0,C,1),t=Dt(x.p0,x.p1,x.p2,C),l=function(e,t,n,r){var i=Tt(0,r-.001,1),a=Tt(0,r+.001,1),o=Dt(e,t,n,i),s=Dt(e,t,n,a);return su(o,s)}(x.p0,x.p1,x.p2,C);break;case"straight":case"segments":case"haystack":for(var S,P,D,T,_=0,M=r.allpts.length,B=0;B+3=c));B+=2);var N=(c-P)/S;N=Tt(0,N,1),t=function(e,t,n,r){var i=t.x-e.x,a=t.y-e.y,o=kt(e,t),s=i/o,l=a/o;return n=null==n?0:n,r=null!=r?r:n*o,{x:e.x+s*r,y:e.y+l*r}}(D,T,N),l=su(D,T)}o("labelX",s,t.x),o("labelY",s,t.y),o("labelAutoAngle",s,l)}};l("source"),l("target"),this.applyLabelDimensions(e)}},au.applyLabelDimensions=function(e){this.applyPrefixedLabelDimensions(e),e.isEdge()&&(this.applyPrefixedLabelDimensions(e,"source"),this.applyPrefixedLabelDimensions(e,"target"))},au.applyPrefixedLabelDimensions=function(e,t){var n=e._private,r=this.getLabelText(e,t),i=this.calculateLabelDimensions(e,r),a=e.pstyle("line-height").pfValue,o=e.pstyle("text-wrap").strValue,s=Ue(n.rscratch,"labelWrapCachedLines",t)||[],l="wrap"!==o?1:Math.max(s.length,1),u=i.height/l,c=u*a,d=i.width,h=i.height+(l-1)*(a-1)*u;Ze(n.rstyle,"labelWidth",t,d),Ze(n.rscratch,"labelWidth",t,d),Ze(n.rstyle,"labelHeight",t,h),Ze(n.rscratch,"labelHeight",t,h),Ze(n.rscratch,"labelLineHeight",t,c)},au.getLabelText=function(e,t){var n=e._private,r=t?t+"-":"",i=e.pstyle(r+"label").strValue,a=e.pstyle("text-transform").value,o=function(e,r){return r?(Ze(n.rscratch,e,t,r),r):Ue(n.rscratch,e,t)};if(!i)return"";"none"==a||("uppercase"==a?i=i.toUpperCase():"lowercase"==a&&(i=i.toLowerCase()));var s=e.pstyle("text-wrap").value;if("wrap"===s){var u=o("labelKey");if(null!=u&&o("labelWrapKey")===u)return o("labelWrapCachedText");for(var c=i.split("\n"),d=e.pstyle("text-max-width").pfValue,h="anywhere"===e.pstyle("text-overflow-wrap").value,p=[],f=/[\s\u200b]+|$/g,g=0;gd){var b,x="",w=0,E=l(v.matchAll(f));try{for(E.s();!(b=E.n()).done;){var k=b.value,C=k[0],S=v.substring(w,k.index);w=k.index+C.length;var P=0===x.length?S:x+S+C;this.calculateLabelDimensions(e,P).width<=d?x+=S+C:(x&&p.push(x),x=S+C)}}catch(e){E.e(e)}finally{E.f()}x.match(/^[\s\u200b]+$/)||p.push(x)}else p.push(v)}o("labelWrapCachedLines",p),i=o("labelWrapCachedText",p.join("\n")),o("labelWrapKey",u)}else if("ellipsis"===s){var D=e.pstyle("text-max-width").pfValue,T="",_=!1;if(this.calculateLabelDimensions(e,i).widthD)break;T+=i[M],M===i.length-1&&(_=!0)}return _||(T+="…"),T}return i},au.getLabelJustification=function(e){var t=e.pstyle("text-justification").strValue,n=e.pstyle("text-halign").strValue;if("auto"!==t)return t;if(!e.isNode())return"center";switch(n){case"left":return"right";case"right":return"left";default:return"center"}},au.calculateLabelDimensions=function(e,t){var n=this,r=n.cy.window().document,i=Te(t,e._private.labelDimsKey),a=n.labelDimCache||(n.labelDimCache=[]),o=a[i];if(null!=o)return o;var s=e.pstyle("font-style").strValue,l=e.pstyle("font-size").pfValue,u=e.pstyle("font-family").strValue,c=e.pstyle("font-weight").strValue,d=this.labelCalcCanvas,h=this.labelCalcCanvasContext;if(!d){d=this.labelCalcCanvas=r.createElement("canvas"),h=this.labelCalcCanvasContext=d.getContext("2d");var p=d.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}h.font="".concat(s," ").concat(c," ").concat(l,"px ").concat(u);for(var f=0,g=0,v=t.split("\n"),y=0;y1&&void 0!==arguments[1])||arguments[1];if(t.merge(e),n)for(var r=0;r=e.desktopTapThreshold2}var D=i(t);v&&(e.hoverData.tapholdCancelled=!0);n=!0,r(g,["mousemove","vmousemove","tapdrag"],t,{x:c[0],y:c[1]});var T=function(){e.data.bgActivePosistion=void 0,e.hoverData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:c[0],y:c[1]}}),f[4]=1,e.hoverData.selecting=!0,e.redrawHint("select",!0),e.redraw()};if(3===e.hoverData.which){if(v){var _={originalEvent:t,type:"cxtdrag",position:{x:c[0],y:c[1]}};b?b.emit(_):o.emit(_),e.hoverData.cxtDragged=!0,e.hoverData.cxtOver&&g===e.hoverData.cxtOver||(e.hoverData.cxtOver&&e.hoverData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:c[0],y:c[1]}}),e.hoverData.cxtOver=g,g&&g.emit({originalEvent:t,type:"cxtdragover",position:{x:c[0],y:c[1]}}))}}else if(e.hoverData.dragging){if(n=!0,o.panningEnabled()&&o.userPanningEnabled()){var M;if(e.hoverData.justStartedPan){var B=e.hoverData.mdownPos;M={x:(c[0]-B[0])*s,y:(c[1]-B[1])*s},e.hoverData.justStartedPan=!1}else M={x:w[0]*s,y:w[1]*s};o.panBy(M),o.emit("dragpan"),e.hoverData.dragged=!0}c=e.projectIntoViewport(t.clientX,t.clientY)}else if(1!=f[4]||null!=b&&!b.pannable()){if(b&&b.pannable()&&b.active()&&b.unactivate(),b&&b.grabbed()||g==y||(y&&r(y,["mouseout","tapdragout"],t,{x:c[0],y:c[1]}),g&&r(g,["mouseover","tapdragover"],t,{x:c[0],y:c[1]}),e.hoverData.last=g),b)if(v){if(o.boxSelectionEnabled()&&D)b&&b.grabbed()&&(d(E),b.emit("freeon"),E.emit("free"),e.dragData.didDrag&&(b.emit("dragfreeon"),E.emit("dragfree"))),T();else if(b&&b.grabbed()&&e.nodeIsDraggable(b)){var N=!e.dragData.didDrag;N&&e.redrawHint("eles",!0),e.dragData.didDrag=!0,e.hoverData.draggingEles||u(E,{inDragLayer:!0});var z={x:0,y:0};if(x(w[0])&&x(w[1])&&(z.x+=w[0],z.y+=w[1],N)){var I=e.hoverData.dragDelta;I&&x(I[0])&&x(I[1])&&(z.x+=I[0],z.y+=I[1])}e.hoverData.draggingEles=!0,E.silentShift(z).emit("position drag"),e.redrawHint("drag",!0),e.redraw()}}else!function(){var t=e.hoverData.dragDelta=e.hoverData.dragDelta||[];0===t.length?(t.push(w[0]),t.push(w[1])):(t[0]+=w[0],t[1]+=w[1])}();n=!0}else if(v){if(e.hoverData.dragging||!o.boxSelectionEnabled()||!D&&o.panningEnabled()&&o.userPanningEnabled()){if(!e.hoverData.selecting&&o.panningEnabled()&&o.userPanningEnabled()){a(b,e.hoverData.downs)&&(e.hoverData.dragging=!0,e.hoverData.justStartedPan=!0,f[4]=0,e.data.bgActivePosistion=bt(h),e.redrawHint("select",!0),e.redraw())}}else T();b&&b.pannable()&&b.active()&&b.unactivate()}return f[2]=c[0],f[3]=c[1],n?(t.stopPropagation&&t.stopPropagation(),t.preventDefault&&t.preventDefault(),!1):void 0}}),!1),e.registerBinding(t,"mouseup",(function(t){if((1!==e.hoverData.which||1===t.which||!e.hoverData.capture)&&e.hoverData.capture){e.hoverData.capture=!1;var a=e.cy,o=e.projectIntoViewport(t.clientX,t.clientY),s=e.selection,l=e.findNearestElement(o[0],o[1],!0,!1),u=e.dragData.possibleDragElements,c=e.hoverData.down,h=i(t);if(e.data.bgActivePosistion&&(e.redrawHint("select",!0),e.redraw()),e.hoverData.tapholdCancelled=!0,e.data.bgActivePosistion=void 0,c&&c.unactivate(),3===e.hoverData.which){var p={originalEvent:t,type:"cxttapend",position:{x:o[0],y:o[1]}};if(c?c.emit(p):a.emit(p),!e.hoverData.cxtDragged){var f={originalEvent:t,type:"cxttap",position:{x:o[0],y:o[1]}};c?c.emit(f):a.emit(f)}e.hoverData.cxtDragged=!1,e.hoverData.which=null}else if(1===e.hoverData.which){if(r(l,["mouseup","tapend","vmouseup"],t,{x:o[0],y:o[1]}),e.dragData.didDrag||e.hoverData.dragged||e.hoverData.selecting||e.hoverData.isOverThresholdDrag||(r(c,["click","tap","vclick"],t,{x:o[0],y:o[1]}),w=!1,t.timeStamp-E<=a.multiClickDebounceTime()?(b&&clearTimeout(b),w=!0,E=null,r(c,["dblclick","dbltap","vdblclick"],t,{x:o[0],y:o[1]})):(b=setTimeout((function(){w||r(c,["oneclick","onetap","voneclick"],t,{x:o[0],y:o[1]})}),a.multiClickDebounceTime()),E=t.timeStamp)),null!=c||e.dragData.didDrag||e.hoverData.selecting||e.hoverData.dragged||i(t)||(a.$(n).unselect(["tapunselect"]),u.length>0&&e.redrawHint("eles",!0),e.dragData.possibleDragElements=u=a.collection()),l!=c||e.dragData.didDrag||e.hoverData.selecting||null!=l&&l._private.selectable&&(e.hoverData.dragging||("additive"===a.selectionType()||h?l.selected()?l.unselect(["tapunselect"]):l.select(["tapselect"]):h||(a.$(n).unmerge(l).unselect(["tapunselect"]),l.select(["tapselect"]))),e.redrawHint("eles",!0)),e.hoverData.selecting){var g=a.collection(e.getAllInBox(s[0],s[1],s[2],s[3]));e.redrawHint("select",!0),g.length>0&&e.redrawHint("eles",!0),a.emit({type:"boxend",originalEvent:t,position:{x:o[0],y:o[1]}});var v=function(e){return e.selectable()&&!e.selected()};"additive"===a.selectionType()||h||a.$(n).unmerge(g).unselect(),g.emit("box").stdFilter(v).select().emit("boxselect"),e.redraw()}if(e.hoverData.dragging&&(e.hoverData.dragging=!1,e.redrawHint("select",!0),e.redrawHint("eles",!0),e.redraw()),!s[4]){e.redrawHint("drag",!0),e.redrawHint("eles",!0);var y=c&&c.grabbed();d(u),y&&(c.emit("freeon"),u.emit("free"),e.dragData.didDrag&&(c.emit("dragfreeon"),u.emit("dragfree")))}}s[4]=0,e.hoverData.down=null,e.hoverData.cxtStarted=!1,e.hoverData.draggingEles=!1,e.hoverData.selecting=!1,e.hoverData.isOverThresholdDrag=!1,e.dragData.didDrag=!1,e.hoverData.dragged=!1,e.hoverData.dragDelta=[],e.hoverData.mdownPos=null,e.hoverData.mdownGPos=null,e.hoverData.which=null}}),!1);var C,S,P,D,T,_,M,B,N,z,I,A,L,O=function(t){if(!e.scrollingPage){var n=e.cy,r=n.zoom(),i=n.pan(),a=e.projectIntoViewport(t.clientX,t.clientY),o=[a[0]*r+i.x,a[1]*r+i.y];if(e.hoverData.draggingEles||e.hoverData.dragging||e.hoverData.cxtStarted||0!==e.selection[4])t.preventDefault();else if(n.panningEnabled()&&n.userPanningEnabled()&&n.zoomingEnabled()&&n.userZoomingEnabled()){var s;t.preventDefault(),e.data.wheelZooming=!0,clearTimeout(e.data.wheelTimeout),e.data.wheelTimeout=setTimeout((function(){e.data.wheelZooming=!1,e.redrawHint("eles",!0),e.redraw()}),150),s=null!=t.deltaY?t.deltaY/-250:null!=t.wheelDeltaY?t.wheelDeltaY/1e3:t.wheelDelta/1e3,s*=e.wheelSensitivity,1===t.deltaMode&&(s*=33);var l=n.zoom()*Math.pow(10,s);"gesturechange"===t.type&&(l=e.gestureStartZoom*t.scale),n.zoom({level:l,renderedPosition:{x:o[0],y:o[1]}}),n.emit("gesturechange"===t.type?"pinchzoom":"scrollzoom")}}};e.registerBinding(e.container,"wheel",O,!0),e.registerBinding(t,"scroll",(function(t){e.scrollingPage=!0,clearTimeout(e.scrollingPageTimeout),e.scrollingPageTimeout=setTimeout((function(){e.scrollingPage=!1}),250)}),!0),e.registerBinding(e.container,"gesturestart",(function(t){e.gestureStartZoom=e.cy.zoom(),e.hasTouchStarted||t.preventDefault()}),!0),e.registerBinding(e.container,"gesturechange",(function(t){e.hasTouchStarted||O(t)}),!0),e.registerBinding(e.container,"mouseout",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseout",position:{x:n[0],y:n[1]}})}),!1),e.registerBinding(e.container,"mouseover",(function(t){var n=e.projectIntoViewport(t.clientX,t.clientY);e.cy.emit({originalEvent:t,type:"mouseover",position:{x:n[0],y:n[1]}})}),!1);var R,V,F,j,q,Y,X,W=function(e,t,n,r){return Math.sqrt((n-e)*(n-e)+(r-t)*(r-t))},H=function(e,t,n,r){return(n-e)*(n-e)+(r-t)*(r-t)};if(e.registerBinding(e.container,"touchstart",R=function(t){if(e.hasTouchStarted=!0,m(t)){p(),e.touchData.capture=!0,e.data.bgActivePosistion=void 0;var n=e.cy,i=e.touchData.now,a=e.touchData.earlier;if(t.touches[0]){var o=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);i[0]=o[0],i[1]=o[1]}if(t.touches[1]){o=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);i[2]=o[0],i[3]=o[1]}if(t.touches[2]){o=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);i[4]=o[0],i[5]=o[1]}if(t.touches[1]){e.touchData.singleTouchMoved=!0,d(e.dragData.touchDragEles);var l=e.findContainerClientCoords();N=l[0],z=l[1],I=l[2],A=l[3],C=t.touches[0].clientX-N,S=t.touches[0].clientY-z,P=t.touches[1].clientX-N,D=t.touches[1].clientY-z,L=0<=C&&C<=I&&0<=P&&P<=I&&0<=S&&S<=A&&0<=D&&D<=A;var h=n.pan(),f=n.zoom();T=W(C,S,P,D),_=H(C,S,P,D),B=[((M=[(C+P)/2,(S+D)/2])[0]-h.x)/f,(M[1]-h.y)/f];if(_<4e4&&!t.touches[2]){var g=e.findNearestElement(i[0],i[1],!0,!0),v=e.findNearestElement(i[2],i[3],!0,!0);return g&&g.isNode()?(g.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=g):v&&v.isNode()?(v.activate().emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start=v):n.emit({originalEvent:t,type:"cxttapstart",position:{x:i[0],y:i[1]}}),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!0,e.touchData.cxtDragged=!1,e.data.bgActivePosistion=void 0,void e.redraw()}}if(t.touches[2])n.boxSelectionEnabled()&&t.preventDefault();else if(t.touches[1]);else if(t.touches[0]){var y=e.findNearestElements(i[0],i[1],!0,!0),b=y[0];if(null!=b&&(b.activate(),e.touchData.start=b,e.touchData.starts=y,e.nodeIsGrabbable(b))){var x=e.dragData.touchDragEles=n.collection(),w=null;e.redrawHint("eles",!0),e.redrawHint("drag",!0),b.selected()?(w=n.$((function(t){return t.selected()&&e.nodeIsGrabbable(t)})),u(w,{addToList:x})):c(b,{addToList:x}),s(b);var E=function(e){return{originalEvent:t,type:e,position:{x:i[0],y:i[1]}}};b.emit(E("grabon")),w?w.forEach((function(e){e.emit(E("grab"))})):b.emit(E("grab"))}r(b,["touchstart","tapstart","vmousedown"],t,{x:i[0],y:i[1]}),null==b&&(e.data.bgActivePosistion={x:o[0],y:o[1]},e.redrawHint("select",!0),e.redraw()),e.touchData.singleTouchMoved=!1,e.touchData.singleTouchStartTime=+new Date,clearTimeout(e.touchData.tapholdTimeout),e.touchData.tapholdTimeout=setTimeout((function(){!1!==e.touchData.singleTouchMoved||e.pinching||e.touchData.selecting||r(e.touchData.start,["taphold"],t,{x:i[0],y:i[1]})}),e.tapholdDuration)}if(t.touches.length>=1){for(var k=e.touchData.startPosition=[null,null,null,null,null,null],O=0;O=e.touchTapThreshold2}if(n&&e.touchData.cxt){t.preventDefault();var E=t.touches[0].clientX-N,k=t.touches[0].clientY-z,M=t.touches[1].clientX-N,I=t.touches[1].clientY-z,A=H(E,k,M,I);if(A/_>=2.25||A>=22500){e.touchData.cxt=!1,e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var O={originalEvent:t,type:"cxttapend",position:{x:s[0],y:s[1]}};e.touchData.start?(e.touchData.start.unactivate().emit(O),e.touchData.start=null):o.emit(O)}}if(n&&e.touchData.cxt){O={originalEvent:t,type:"cxtdrag",position:{x:s[0],y:s[1]}};e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.touchData.start?e.touchData.start.emit(O):o.emit(O),e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxtDragged=!0;var R=e.findNearestElement(s[0],s[1],!0,!0);e.touchData.cxtOver&&R===e.touchData.cxtOver||(e.touchData.cxtOver&&e.touchData.cxtOver.emit({originalEvent:t,type:"cxtdragout",position:{x:s[0],y:s[1]}}),e.touchData.cxtOver=R,R&&R.emit({originalEvent:t,type:"cxtdragover",position:{x:s[0],y:s[1]}}))}else if(n&&t.touches[2]&&o.boxSelectionEnabled())t.preventDefault(),e.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,e.touchData.selecting||o.emit({originalEvent:t,type:"boxstart",position:{x:s[0],y:s[1]}}),e.touchData.selecting=!0,e.touchData.didSelect=!0,i[4]=1,i&&0!==i.length&&void 0!==i[0]?(i[2]=(s[0]+s[2]+s[4])/3,i[3]=(s[1]+s[3]+s[5])/3):(i[0]=(s[0]+s[2]+s[4])/3,i[1]=(s[1]+s[3]+s[5])/3,i[2]=(s[0]+s[2]+s[4])/3+1,i[3]=(s[1]+s[3]+s[5])/3+1),e.redrawHint("select",!0),e.redraw();else if(n&&t.touches[1]&&!e.touchData.didSelect&&o.zoomingEnabled()&&o.panningEnabled()&&o.userZoomingEnabled()&&o.userPanningEnabled()){if(t.preventDefault(),e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),ee=e.dragData.touchDragEles){e.redrawHint("drag",!0);for(var V=0;V0&&!e.hoverData.draggingEles&&!e.swipePanning&&null!=e.data.bgActivePosistion&&(e.data.bgActivePosistion=void 0,e.redrawHint("select",!0),e.redraw())}},!1),e.registerBinding(t,"touchcancel",F=function(t){var n=e.touchData.start;e.touchData.capture=!1,n&&n.unactivate()}),e.registerBinding(t,"touchend",j=function(t){var i=e.touchData.start;if(e.touchData.capture){0===t.touches.length&&(e.touchData.capture=!1),t.preventDefault();var a=e.selection;e.swipePanning=!1,e.hoverData.draggingEles=!1;var o,s=e.cy,l=s.zoom(),u=e.touchData.now,c=e.touchData.earlier;if(t.touches[0]){var h=e.projectIntoViewport(t.touches[0].clientX,t.touches[0].clientY);u[0]=h[0],u[1]=h[1]}if(t.touches[1]){h=e.projectIntoViewport(t.touches[1].clientX,t.touches[1].clientY);u[2]=h[0],u[3]=h[1]}if(t.touches[2]){h=e.projectIntoViewport(t.touches[2].clientX,t.touches[2].clientY);u[4]=h[0],u[5]=h[1]}if(i&&i.unactivate(),e.touchData.cxt){if(o={originalEvent:t,type:"cxttapend",position:{x:u[0],y:u[1]}},i?i.emit(o):s.emit(o),!e.touchData.cxtDragged){var p={originalEvent:t,type:"cxttap",position:{x:u[0],y:u[1]}};i?i.emit(p):s.emit(p)}return e.touchData.start&&(e.touchData.start._private.grabbed=!1),e.touchData.cxt=!1,e.touchData.start=null,void e.redraw()}if(!t.touches[2]&&s.boxSelectionEnabled()&&e.touchData.selecting){e.touchData.selecting=!1;var f=s.collection(e.getAllInBox(a[0],a[1],a[2],a[3]));a[0]=void 0,a[1]=void 0,a[2]=void 0,a[3]=void 0,a[4]=0,e.redrawHint("select",!0),s.emit({type:"boxend",originalEvent:t,position:{x:u[0],y:u[1]}});f.emit("box").stdFilter((function(e){return e.selectable()&&!e.selected()})).select().emit("boxselect"),f.nonempty()&&e.redrawHint("eles",!0),e.redraw()}if(null!=i&&i.unactivate(),t.touches[2])e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);else if(t.touches[1]);else if(t.touches[0]);else if(!t.touches[0]){e.data.bgActivePosistion=void 0,e.redrawHint("select",!0);var g=e.dragData.touchDragEles;if(null!=i){var v=i._private.grabbed;d(g),e.redrawHint("drag",!0),e.redrawHint("eles",!0),v&&(i.emit("freeon"),g.emit("free"),e.dragData.didDrag&&(i.emit("dragfreeon"),g.emit("dragfree"))),r(i,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]}),i.unactivate(),e.touchData.start=null}else{var y=e.findNearestElement(u[0],u[1],!0,!0);r(y,["touchend","tapend","vmouseup","tapdragout"],t,{x:u[0],y:u[1]})}var m=e.touchData.startPosition[0]-u[0],b=m*m,x=e.touchData.startPosition[1]-u[1],w=(b+x*x)*l*l;e.touchData.singleTouchMoved||(i||s.$(":selected").unselect(["tapunselect"]),r(i,["tap","vclick"],t,{x:u[0],y:u[1]}),q=!1,t.timeStamp-X<=s.multiClickDebounceTime()?(Y&&clearTimeout(Y),q=!0,X=null,r(i,["dbltap","vdblclick"],t,{x:u[0],y:u[1]})):(Y=setTimeout((function(){q||r(i,["onetap","voneclick"],t,{x:u[0],y:u[1]})}),s.multiClickDebounceTime()),X=t.timeStamp)),null!=i&&!e.dragData.didDrag&&i._private.selectable&&w2){for(var p=[c[0],c[1]],f=Math.pow(p[0]-e,2)+Math.pow(p[1]-t,2),g=1;g0)return g[0]}return null},p=Object.keys(d),f=0;f0?u:Rt(i,a,e,t,n,r,o,s)},checkPoint:function(e,t,n,r,i,a,o,s){var l=2*(s="auto"===s?nn(r,i):s);if(Xt(e,t,this.points,a,o,r,i-l,[0,-1],n))return!0;if(Xt(e,t,this.points,a,o,r-l,i,[0,-1],n))return!0;var u=r/2+2*n,c=i/2+2*n;return!!Yt(e,t,[a-u,o-c,a-u,o,a+u,o,a+u,o-c])||(!!Kt(e,t,l,l,a+r/2-s,o+i/2-s,n)||!!Kt(e,t,l,l,a-r/2+s,o+i/2-s,n))}}},gu.registerNodeShapes=function(){var e=this.nodeShapes={},t=this;this.generateEllipse(),this.generatePolygon("triangle",Jt(3,0)),this.generateRoundPolygon("round-triangle",Jt(3,0)),this.generatePolygon("rectangle",Jt(4,0)),e.square=e.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();var n=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",n),this.generateRoundPolygon("round-diamond",n),this.generatePolygon("pentagon",Jt(5,0)),this.generateRoundPolygon("round-pentagon",Jt(5,0)),this.generatePolygon("hexagon",Jt(6,0)),this.generateRoundPolygon("round-hexagon",Jt(6,0)),this.generatePolygon("heptagon",Jt(7,0)),this.generateRoundPolygon("round-heptagon",Jt(7,0)),this.generatePolygon("octagon",Jt(8,0)),this.generateRoundPolygon("round-octagon",Jt(8,0));var r=new Array(20),i=tn(5,0),a=tn(5,Math.PI/5),o=.5*(3-Math.sqrt(5));o*=1.57;for(var s=0;s=e.deqFastCost*g)break}else if(i){if(p>=e.deqCost*l||p>=e.deqAvgCost*s)break}else if(f>=e.deqNoDrawCost*(1e3/60))break;var v=e.deq(t,d,c);if(!(v.length>0))break;for(var y=0;y0&&(e.onDeqd(t,u),!i&&e.shouldRedraw(t,u,d,c)&&r())}),i(t))}}},wu=function(){function e(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Le;t(this,e),this.idsByKey=new $e,this.keyForId=new $e,this.cachesByLvl=new $e,this.lvls=[],this.getKey=n,this.doesEleInvalidateKey=r}return r(e,[{key:"getIdsFor",value:function(e){null==e&&Ve("Can not get id list for null key");var t=this.idsByKey,n=this.idsByKey.get(e);return n||(n=new Je,t.set(e,n)),n}},{key:"addIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).add(t)}},{key:"deleteIdForKey",value:function(e,t){null!=e&&this.getIdsFor(e).delete(t)}},{key:"getNumberOfIdsForKey",value:function(e){return null==e?0:this.getIdsFor(e).size}},{key:"updateKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t),r=this.getKey(e);this.deleteIdForKey(n,t),this.addIdForKey(r,t),this.keyForId.set(t,r)}},{key:"deleteKeyMappingFor",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteIdForKey(n,t),this.keyForId.delete(t)}},{key:"keyHasChangedFor",value:function(e){var t=e.id();return this.keyForId.get(t)!==this.getKey(e)}},{key:"isInvalid",value:function(e){return this.keyHasChangedFor(e)||this.doesEleInvalidateKey(e)}},{key:"getCachesAt",value:function(e){var t=this.cachesByLvl,n=this.lvls,r=t.get(e);return r||(r=new $e,t.set(e,r),n.push(e)),r}},{key:"getCache",value:function(e,t){return this.getCachesAt(t).get(e)}},{key:"get",value:function(e,t){var n=this.getKey(e),r=this.getCache(n,t);return null!=r&&this.updateKeyMappingFor(e),r}},{key:"getForCachedKey",value:function(e,t){var n=this.keyForId.get(e.id());return this.getCache(n,t)}},{key:"hasCache",value:function(e,t){return this.getCachesAt(t).has(e)}},{key:"has",value:function(e,t){var n=this.getKey(e);return this.hasCache(n,t)}},{key:"setCache",value:function(e,t,n){n.key=e,this.getCachesAt(t).set(e,n)}},{key:"set",value:function(e,t,n){var r=this.getKey(e);this.setCache(r,t,n),this.updateKeyMappingFor(e)}},{key:"deleteCache",value:function(e,t){this.getCachesAt(t).delete(e)}},{key:"delete",value:function(e,t){var n=this.getKey(e);this.deleteCache(n,t)}},{key:"invalidateKey",value:function(e){var t=this;this.lvls.forEach((function(n){return t.deleteCache(e,n)}))}},{key:"invalidate",value:function(e){var t=e.id(),n=this.keyForId.get(t);this.deleteKeyMappingFor(e);var r=this.doesEleInvalidateKey(e);return r&&this.invalidateKey(n),r||0===this.getNumberOfIdsForKey(n)}}]),e}(),Eu={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},ku=He({getKey:null,doesEleInvalidateKey:Le,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Ae,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),Cu=function(e,t){this.renderer=e,this.onDequeues=[];var n=ku(t);L(this,n),this.lookup=new wu(n.getKey,n.doesEleInvalidateKey),this.setupDequeueing()},Su=Cu.prototype;Su.reasons=Eu,Su.getTextureQueue=function(e){return this.eleImgCaches=this.eleImgCaches||{},this.eleImgCaches[e]=this.eleImgCaches[e]||[]},Su.getRetiredTextureQueue=function(e){var t=this.eleImgCaches.retired=this.eleImgCaches.retired||{};return t[e]=t[e]||[]},Su.getElementQueue=function(){return this.eleCacheQueue=this.eleCacheQueue||new rt((function(e,t){return t.reqs-e.reqs}))},Su.getElementKeyToQueue=function(){return this.eleKeyToCacheQueue=this.eleKeyToCacheQueue||{}},Su.getElement=function(e,t,n,r,i){var a=this,o=this.renderer,s=o.cy.zoom(),l=this.lookup;if(!t||0===t.w||0===t.h||isNaN(t.w)||isNaN(t.h)||!e.visible()||e.removed())return null;if(!a.allowEdgeTxrCaching&&e.isEdge()||!a.allowParentTxrCaching&&e.isParent())return null;if(null==r&&(r=Math.ceil(wt(s*n))),r<-4)r=-4;else if(s>=7.99||r>3)return null;var u=Math.pow(2,r),c=t.h*u,d=t.w*u,h=o.eleTextBiggerThanMin(e,u);if(!this.isVisible(e,h))return null;var p,f=l.get(e,r);if(f&&f.invalidated&&(f.invalidated=!1,f.texture.invalidatedWidth-=f.width),f)return f;if(p=c<=25?25:c<=50?50:50*Math.ceil(c/50),c>1024||d>1024)return null;var g=a.getTextureQueue(p),v=g[g.length-2],y=function(){return a.recycleTexture(p,d)||a.addTexture(p,d)};v||(v=g[g.length-1]),v||(v=y()),v.width-v.usedWidthr;D--)S=a.getElement(e,t,n,D,Eu.downscale);P()}else{var T;if(!x&&!w&&!E)for(var _=r-1;_>=-4;_--){var M=l.get(e,_);if(M){T=M;break}}if(b(T))return a.queueElement(e,r),T;v.context.translate(v.usedWidth,0),v.context.scale(u,u),this.drawElement(v.context,e,t,h,!1),v.context.scale(1/u,1/u),v.context.translate(-v.usedWidth,0)}return f={x:v.usedWidth,texture:v,level:r,scale:u,width:d,height:c,scaledLabelShown:h},v.usedWidth+=Math.ceil(d+8),v.eleCaches.push(f),l.set(e,r,f),a.checkTextureFullness(v),f},Su.invalidateElements=function(e){for(var t=0;t=.2*e.width&&this.retireTexture(e)},Su.checkTextureFullness=function(e){var t=this.getTextureQueue(e.height);e.usedWidth/e.width>.8&&e.fullnessChecks>=10?Ke(t,e):e.fullnessChecks++},Su.retireTexture=function(e){var t=e.height,n=this.getTextureQueue(t),r=this.lookup;Ke(n,e),e.retired=!0;for(var i=e.eleCaches,a=0;a=t)return a.retired=!1,a.usedWidth=0,a.invalidatedWidth=0,a.fullnessChecks=0,Ge(a.eleCaches),a.context.setTransform(1,0,0,1,0,0),a.context.clearRect(0,0,a.width,a.height),Ke(r,a),n.push(a),a}},Su.queueElement=function(e,t){var n=this.getElementQueue(),r=this.getElementKeyToQueue(),i=this.getKey(e),a=r[i];if(a)a.level=Math.max(a.level,t),a.eles.merge(e),a.reqs++,n.updateItem(a);else{var o={eles:e.spawn().merge(e),level:t,reqs:1,key:i};n.push(o),r[i]=o}},Su.dequeue=function(e){for(var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=[],i=this.lookup,a=0;a<1&&t.size()>0;a++){var o=t.pop(),s=o.key,l=o.eles[0],u=i.hasCache(l,o.level);if(n[s]=null,!u){r.push(o);var c=this.getBoundingBox(l);this.getElement(l,c,e,o.level,Eu.dequeue)}}return r},Su.removeFromQueue=function(e){var t=this.getElementQueue(),n=this.getElementKeyToQueue(),r=this.getKey(e),i=n[r];null!=i&&(1===i.eles.length?(i.reqs=Ie,t.updateItem(i),t.pop(),n[r]=null):i.eles.unmerge(e))},Su.onDequeue=function(e){this.onDequeues.push(e)},Su.offDequeue=function(e){Ke(this.onDequeues,e)},Su.setupDequeueing=xu({deqRedrawThreshold:100,deqCost:.15,deqAvgCost:.1,deqNoDrawCost:.9,deqFastCost:.9,deq:function(e,t,n){return e.dequeue(t,n)},onDeqd:function(e,t){for(var n=0;n=3.99||n>2)return null;r.validateLayersElesOrdering(n,e);var o,s,l=r.layersByLevel,u=Math.pow(2,n),c=l[n]=l[n]||[];if(r.levelIsComplete(n,e))return c;!function(){var t=function(t){if(r.validateLayersElesOrdering(t,e),r.levelIsComplete(t,e))return s=l[t],!0},i=function(e){if(!s)for(var r=n+e;-4<=r&&r<=2&&!t(r);r+=e);};i(1),i(-1);for(var a=c.length-1;a>=0;a--){var o=c[a];o.invalid&&Ke(c,o)}}();var d=function(t){var i=(t=t||{}).after;!function(){if(!o){o=_t();for(var t=0;t32767||s>32767)return null;if(a*s>16e6)return null;var l=r.makeLayer(o,n);if(null!=i){var d=c.indexOf(i)+1;c.splice(d,0,l)}else(void 0===t.insert||t.insert)&&c.unshift(l);return l};if(r.skipping&&!a)return null;for(var h=null,p=e.length/1,f=!a,g=0;g=p||!Ot(h.bb,v.boundingBox()))&&!(h=d({insert:!0,after:h})))return null;s||f?r.queueLayer(h,v):r.drawEleInLayer(h,v,n,t),h.eles.push(v),m[n]=h}}return s||(f?null:c)},Du.getEleLevelForLayerLevel=function(e,t){return e},Du.drawEleInLayer=function(e,t,n,r){var i=this.renderer,a=e.context,o=t.boundingBox();0!==o.w&&0!==o.h&&t.visible()&&(n=this.getEleLevelForLayerLevel(n,r),i.setImgSmoothing(a,!1),i.drawCachedElement(a,t,null,null,n,!0),i.setImgSmoothing(a,!0))},Du.levelIsComplete=function(e,t){var n=this.layersByLevel[e];if(!n||0===n.length)return!1;for(var r=0,i=0;i0)return!1;if(a.invalid)return!1;r+=a.eles.length}return r===t.length},Du.validateLayersElesOrdering=function(e,t){var n=this.layersByLevel[e];if(n)for(var r=0;r0){e=!0;break}}return e},Du.invalidateElements=function(e){var t=this;0!==e.length&&(t.lastInvalidationTime=we(),0!==e.length&&t.haveLayers()&&t.updateElementsInLayers(e,(function(e,n,r){t.invalidateLayer(e)})))},Du.invalidateLayer=function(e){if(this.lastInvalidationTime=we(),!e.invalid){var t=e.level,n=e.eles,r=this.layersByLevel[t];Ke(r,e),e.elesQueue=[],e.invalid=!0,e.replacement&&(e.replacement.invalid=!0);for(var i=0;i3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],o=this,s=t._private.rscratch;if((!a||t.visible())&&!s.badLine&&null!=s.allpts&&!isNaN(s.allpts[0])){var l;n&&(l=n,e.translate(-l.x1,-l.y1));var u=a?t.pstyle("opacity").value:1,c=a?t.pstyle("line-opacity").value:1,d=t.pstyle("curve-style").value,h=t.pstyle("line-style").value,p=t.pstyle("width").pfValue,f=t.pstyle("line-cap").value,g=t.pstyle("line-outline-width").value,v=t.pstyle("line-outline-color").value,y=u*c,m=u*c,b=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;"straight-triangle"===d?(o.eleStrokeStyle(e,t,n),o.drawEdgeTrianglePath(t,e,s.allpts)):(e.lineWidth=p,e.lineCap=f,o.eleStrokeStyle(e,t,n),o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")},x=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:y;e.lineWidth=p+g,e.lineCap=f,g>0?(o.colorStrokeStyle(e,v[0],v[1],v[2],n),"straight-triangle"===d?o.drawEdgeTrianglePath(t,e,s.allpts):(o.drawEdgePath(t,e,s.allpts,h),e.lineCap="butt")):e.lineCap="butt"},w=function(){i&&o.drawEdgeOverlay(e,t)},E=function(){i&&o.drawEdgeUnderlay(e,t)},k=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:m;o.drawArrowheads(e,t,n)},C=function(){o.drawElementText(e,t,null,r)};e.lineJoin="round";var S="yes"===t.pstyle("ghost").value;if(S){var P=t.pstyle("ghost-offset-x").pfValue,D=t.pstyle("ghost-offset-y").pfValue,T=t.pstyle("ghost-opacity").value,_=y*T;e.translate(P,D),b(_),k(_),e.translate(-P,-D)}else x();E(),b(),k(),w(),C(),n&&e.translate(l.x1,l.y1)}}},Wu=function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(t,n){if(n.visible()){var r=n.pstyle("".concat(e,"-opacity")).value;if(0!==r){var i=this,a=i.usePaths(),o=n._private.rscratch,s=2*n.pstyle("".concat(e,"-padding")).pfValue,l=n.pstyle("".concat(e,"-color")).value;t.lineWidth=s,"self"!==o.edgeType||a?t.lineCap="round":t.lineCap="butt",i.colorStrokeStyle(t,l[0],l[1],l[2],r),i.drawEdgePath(n,t,o.allpts,"solid")}}}};Xu.drawEdgeOverlay=Wu("overlay"),Xu.drawEdgeUnderlay=Wu("underlay"),Xu.drawEdgePath=function(e,t,n,r){var i,a=e._private.rscratch,o=t,s=!1,u=this.usePaths(),c=e.pstyle("line-dash-pattern").pfValue,d=e.pstyle("line-dash-offset").pfValue;if(u){var h=n.join("$");a.pathCacheKey&&a.pathCacheKey===h?(i=t=a.pathCache,s=!0):(i=t=new Path2D,a.pathCacheKey=h,a.pathCache=i)}if(o.setLineDash)switch(r){case"dotted":o.setLineDash([1,1]);break;case"dashed":o.setLineDash(c),o.lineDashOffset=d;break;case"solid":o.setLineDash([])}if(!s&&!a.badLine)switch(t.beginPath&&t.beginPath(),t.moveTo(n[0],n[1]),a.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var p=2;p+35&&void 0!==arguments[5]?arguments[5]:5,o=arguments.length>6?arguments[6]:void 0;e.beginPath(),e.moveTo(t+a,n),e.lineTo(t+r-a,n),e.quadraticCurveTo(t+r,n,t+r,n+a),e.lineTo(t+r,n+i-a),e.quadraticCurveTo(t+r,n+i,t+r-a,n+i),e.lineTo(t+a,n+i),e.quadraticCurveTo(t,n+i,t,n+i-a),e.lineTo(t,n+a),e.quadraticCurveTo(t,n,t+a,n),e.closePath(),o?e.stroke():e.fill()}Ku.eleTextBiggerThanMin=function(e,t){if(!t){var n=e.cy().zoom(),r=this.getPixelRatio(),i=Math.ceil(wt(n*r));t=Math.pow(2,i)}return!(e.pstyle("font-size").pfValue*t5&&void 0!==arguments[5])||arguments[5],o=this;if(null==r){if(a&&!o.eleTextBiggerThanMin(t))return}else if(!1===r)return;if(t.isNode()){var s=t.pstyle("label");if(!s||!s.value)return;var l=o.getLabelJustification(t);e.textAlign=l,e.textBaseline="bottom"}else{var u=t.element()._private.rscratch.badLine,c=t.pstyle("label"),d=t.pstyle("source-label"),h=t.pstyle("target-label");if(u||(!c||!c.value)&&(!d||!d.value)&&(!h||!h.value))return;e.textAlign="center",e.textBaseline="bottom"}var p,f=!n;n&&(p=n,e.translate(-p.x1,-p.y1)),null==i?(o.drawText(e,t,null,f,a),t.isEdge()&&(o.drawText(e,t,"source",f,a),o.drawText(e,t,"target",f,a))):o.drawText(e,t,i,f,a),n&&e.translate(p.x1,p.y1)},Ku.getFontCache=function(e){var t;this.fontCaches=this.fontCaches||[];for(var n=0;n2&&void 0!==arguments[2])||arguments[2],r=t.pstyle("font-style").strValue,i=t.pstyle("font-size").pfValue+"px",a=t.pstyle("font-family").strValue,o=t.pstyle("font-weight").strValue,s=n?t.effectiveOpacity()*t.pstyle("text-opacity").value:1,l=t.pstyle("text-outline-opacity").value*s,u=t.pstyle("color").value,c=t.pstyle("text-outline-color").value;e.font=r+" "+o+" "+i+" "+a,e.lineJoin="round",this.colorFillStyle(e,u[0],u[1],u[2],s),this.colorStrokeStyle(e,c[0],c[1],c[2],l)},Ku.getTextAngle=function(e,t){var n=e._private.rscratch,r=t?t+"-":"",i=e.pstyle(r+"text-rotation"),a=Ue(n,"labelAngle",t);return"autorotate"===i.strValue?e.isEdge()?a:0:"none"===i.strValue?0:i.pfValue},Ku.drawText=function(e,t,n){var r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=t._private,o=a.rscratch,s=i?t.effectiveOpacity():1;if(!i||0!==s&&0!==t.pstyle("text-opacity").value){"main"===n&&(n=null);var l,u,c=Ue(o,"labelX",n),d=Ue(o,"labelY",n),h=this.getLabelText(t,n);if(null!=h&&""!==h&&!isNaN(c)&&!isNaN(d)){this.setupTextStyle(e,t,i);var p,f=n?n+"-":"",g=Ue(o,"labelWidth",n),v=Ue(o,"labelHeight",n),y=t.pstyle(f+"text-margin-x").pfValue,m=t.pstyle(f+"text-margin-y").pfValue,b=t.isEdge(),x=t.pstyle("text-halign").value,w=t.pstyle("text-valign").value;switch(b&&(x="center",w="center"),c+=y,d+=m,0!==(p=r?this.getTextAngle(t,n):0)&&(l=c,u=d,e.translate(l,u),e.rotate(p),c=0,d=0),w){case"top":break;case"center":d+=v/2;break;case"bottom":d+=v}var E=t.pstyle("text-background-opacity").value,k=t.pstyle("text-border-opacity").value,C=t.pstyle("text-border-width").pfValue,S=t.pstyle("text-background-padding").pfValue,P=t.pstyle("text-background-shape").strValue,D=0===P.indexOf("round"),T=2;if(E>0||C>0&&k>0){var _=c-S;switch(x){case"left":_-=g;break;case"center":_-=g/2}var M=d-v-S,B=g+2*S,N=v+2*S;if(E>0){var z=e.fillStyle,I=t.pstyle("text-background-color").value;e.fillStyle="rgba("+I[0]+","+I[1]+","+I[2]+","+E*s+")",D?Gu(e,_,M,B,N,T):e.fillRect(_,M,B,N),e.fillStyle=z}if(C>0&&k>0){var A=e.strokeStyle,L=e.lineWidth,O=t.pstyle("text-border-color").value,R=t.pstyle("text-border-style").value;if(e.strokeStyle="rgba("+O[0]+","+O[1]+","+O[2]+","+k*s+")",e.lineWidth=C,e.setLineDash)switch(R){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"double":e.lineWidth=C/4,e.setLineDash([]);break;case"solid":e.setLineDash([])}if(D?Gu(e,_,M,B,N,T,"stroke"):e.strokeRect(_,M,B,N),"double"===R){var V=C/2;D?Gu(e,_+V,M+V,B-2*V,N-2*V,T,"stroke"):e.strokeRect(_+V,M+V,B-2*V,N-2*V)}e.setLineDash&&e.setLineDash([]),e.lineWidth=L,e.strokeStyle=A}}var F=2*t.pstyle("text-outline-width").pfValue;if(F>0&&(e.lineWidth=F),"wrap"===t.pstyle("text-wrap").value){var j=Ue(o,"labelWrapCachedLines",n),q=Ue(o,"labelLineHeight",n),Y=g/2,X=this.getLabelJustification(t);switch("auto"===X||("left"===x?"left"===X?c+=-g:"center"===X&&(c+=-Y):"center"===x?"left"===X?c+=-Y:"right"===X&&(c+=Y):"right"===x&&("center"===X?c+=Y:"right"===X&&(c+=g))),w){case"top":d-=(j.length-1)*q;break;case"center":case"bottom":d-=(j.length-1)*q}for(var W=0;W0&&e.strokeText(j[W],c,d),e.fillText(j[W],c,d),d+=q}else F>0&&e.strokeText(h,c,d),e.fillText(h,c,d);0!==p&&(e.rotate(-p),e.translate(-l,-u))}}};var Uu={drawNode:function(e,t,n){var r,i,a=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],s=!(arguments.length>5&&void 0!==arguments[5])||arguments[5],l=this,u=t._private,c=u.rscratch,d=t.position();if(x(d.x)&&x(d.y)&&(!s||t.visible())){var h,p,f=s?t.effectiveOpacity():1,g=l.usePaths(),v=!1,y=t.padding();r=t.width()+2*y,i=t.height()+2*y,n&&(p=n,e.translate(-p.x1,-p.y1));for(var m=t.pstyle("background-image"),b=m.value,w=new Array(b.length),E=new Array(b.length),k=0,C=0;C0&&void 0!==arguments[0]?arguments[0]:M;l.eleFillStyle(e,t,n)},H=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:R;l.colorStrokeStyle(e,B[0],B[1],B[2],t)},K=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:q;l.colorStrokeStyle(e,F[0],F[1],F[2],t)},G=function(e,t,n,r){var i,a=l.nodePathCache=l.nodePathCache||[],o=_e("polygon"===n?n+","+r.join(","):n,""+t,""+e,""+X),s=a[o],u=!1;return null!=s?(i=s,u=!0,c.pathCache=i):(i=new Path2D,a[o]=c.pathCache=i),{path:i,cacheHit:u}},U=t.pstyle("shape").strValue,Z=t.pstyle("shape-polygon-points").pfValue;if(g){e.translate(d.x,d.y);var $=G(r,i,U,Z);h=$.path,v=$.cacheHit}var Q=function(){if(!v){var n=d;g&&(n={x:0,y:0}),l.nodeShapes[l.getNodeShape(t)].draw(h||e,n.x,n.y,r,i,X,c)}g?e.fill(h):e.fill()},J=function(){for(var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,r=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=u.backgrounding,a=0,o=0;o0&&void 0!==arguments[0]&&arguments[0],a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:f;l.hasPie(t)&&(l.drawPie(e,t,a),n&&(g||l.nodeShapes[l.getNodeShape(t)].draw(e,d.x,d.y,r,i,X,c)))},te=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:f,n=(T>0?T:-T)*t,r=T>0?0:255;0!==T&&(l.colorFillStyle(e,r,r,r,n),g?e.fill(h):e.fill())},ne=function(){if(_>0){if(e.lineWidth=_,e.lineCap=I,e.lineJoin=z,e.setLineDash)switch(N){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash(L),e.lineDashOffset=O;break;case"solid":case"double":e.setLineDash([])}if("center"!==A){if(e.save(),e.lineWidth*=2,"inside"===A)g?e.clip(h):e.clip();else{var t=new Path2D;t.rect(-r/2-_,-i/2-_,r+2*_,i+2*_),t.addPath(h),e.clip(t,"evenodd")}g?e.stroke(h):e.stroke(),e.restore()}else g?e.stroke(h):e.stroke();if("double"===N){e.lineWidth=_/3;var n=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",g?e.stroke(h):e.stroke(),e.globalCompositeOperation=n}e.setLineDash&&e.setLineDash([])}},re=function(){if(V>0){if(e.lineWidth=V,e.lineCap="butt",e.setLineDash)switch(j){case"dotted":e.setLineDash([1,1]);break;case"dashed":e.setLineDash([4,2]);break;case"solid":case"double":e.setLineDash([])}var n=d;g&&(n={x:0,y:0});var a=l.getNodeShape(t),o=_;"inside"===A&&(o=0),"outside"===A&&(o*=2);var s,u=(r+o+(V+Y))/r,c=(i+o+(V+Y))/i,h=r*u,p=i*c,f=l.nodeShapes[a].points;if(g)s=G(h,p,a,f).path;if("ellipse"===a)l.drawEllipsePath(s||e,n.x,n.y,h,p);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(a)){var v=0,y=0,m=0;"round-diamond"===a?v=1.4*(o+Y+V):"round-heptagon"===a?(v=1.075*(o+Y+V),m=-(o/2+Y+V)/35):"round-hexagon"===a?v=1.12*(o+Y+V):"round-pentagon"===a?(v=1.13*(o+Y+V),m=-(o/2+Y+V)/15):"round-tag"===a?(v=1.12*(o+Y+V),y=.07*(o/2+V+Y)):"round-triangle"===a&&(v=(o+Y+V)*(Math.PI/2),m=-(o+Y/2+V)/Math.PI),0!==v&&(h=r*(u=(r+v)/r),["round-hexagon","round-tag"].includes(a)||(p=i*(c=(i+v)/i)));for(var b=h/2,x=p/2,w=(X="auto"===X?rn(h,p):X)+(o+V+Y)/2,E=new Array(f.length/2),k=new Array(f.length/2),C=0;C0){if(r=r||n.position(),null==i||null==a){var d=n.padding();i=n.width()+2*d,a=n.height()+2*d}this.colorFillStyle(t,l[0],l[1],l[2],s),this.nodeShapes[u].draw(t,r.x,r.y,i+2*o,a+2*o,c),t.fill()}}}};Uu.drawNodeOverlay=Zu("overlay"),Uu.drawNodeUnderlay=Zu("underlay"),Uu.hasPie=function(e){return(e=e[0])._private.hasPie},Uu.drawPie=function(e,t,n,r){t=t[0],r=r||t.position();var i=t.cy().style(),a=t.pstyle("pie-size"),o=r.x,s=r.y,l=t.width(),u=t.height(),c=Math.min(l,u)/2,d=0;this.usePaths()&&(o=0,s=0),"%"===a.units?c*=a.pfValue:void 0!==a.pfValue&&(c=a.pfValue/2);for(var h=1;h<=i.pieBackgroundN;h++){var p=t.pstyle("pie-"+h+"-background-size").value,f=t.pstyle("pie-"+h+"-background-color").value,g=t.pstyle("pie-"+h+"-background-opacity").value*n,v=p/100;v+d>1&&(v=1-d);var y=1.5*Math.PI+2*Math.PI*d,m=y+2*Math.PI*v;0===p||d>=1||d+v>1||(e.beginPath(),e.moveTo(o,s),e.arc(o,s,c,y,m),e.closePath(),this.colorFillStyle(e,f[0],f[1],f[2],g),e.fill(),d+=v)}};var $u={};$u.getPixelRatio=function(){var e=this.data.contexts[0];if(null!=this.forcedPixelRatio)return this.forcedPixelRatio;var t=this.cy.window(),n=e.backingStorePixelRatio||e.webkitBackingStorePixelRatio||e.mozBackingStorePixelRatio||e.msBackingStorePixelRatio||e.oBackingStorePixelRatio||e.backingStorePixelRatio||1;return(t.devicePixelRatio||1)/n},$u.paintCache=function(e){for(var t,n=this.paintCaches=this.paintCaches||[],r=!0,i=0;io.minMbLowQualFrames&&(o.motionBlurPxRatio=o.mbPxRBlurry)),o.clearingMotionBlur&&(o.motionBlurPxRatio=1),o.textureDrawLastFrame&&!d&&(c[o.NODE]=!0,c[o.SELECT_BOX]=!0);var m=l.style(),b=l.zoom(),x=void 0!==i?i:b,w=l.pan(),E={x:w.x,y:w.y},k={zoom:b,pan:{x:w.x,y:w.y}},C=o.prevViewport;void 0===C||k.zoom!==C.zoom||k.pan.x!==C.pan.x||k.pan.y!==C.pan.y||g&&!f||(o.motionBlurPxRatio=1),a&&(E=a),x*=s,E.x*=s,E.y*=s;var S=o.getCachedZSortedEles();function P(e,t,n,r,i){var a=e.globalCompositeOperation;e.globalCompositeOperation="destination-out",o.colorFillStyle(e,255,255,255,o.motionBlurTransparency),e.fillRect(t,n,r,i),e.globalCompositeOperation=a}function D(e,r){var s,l,c,d;o.clearingMotionBlur||e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]&&e!==u.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]?(s=E,l=x,c=o.canvasWidth,d=o.canvasHeight):(s={x:w.x*p,y:w.y*p},l=b*p,c=o.canvasWidth*p,d=o.canvasHeight*p),e.setTransform(1,0,0,1,0,0),"motionBlur"===r?P(e,0,0,c,d):t||void 0!==r&&!r||e.clearRect(0,0,c,d),n||(e.translate(s.x,s.y),e.scale(l,l)),a&&e.translate(a.x,a.y),i&&e.scale(i,i)}if(d||(o.textureDrawLastFrame=!1),d){if(o.textureDrawLastFrame=!0,!o.textureCache){o.textureCache={},o.textureCache.bb=l.mutableElements().boundingBox(),o.textureCache.texture=o.data.bufferCanvases[o.TEXTURE_BUFFER];var T=o.data.bufferContexts[o.TEXTURE_BUFFER];T.setTransform(1,0,0,1,0,0),T.clearRect(0,0,o.canvasWidth*o.textureMult,o.canvasHeight*o.textureMult),o.render({forcedContext:T,drawOnlyNodeLayer:!0,forcedPxRatio:s*o.textureMult}),(k=o.textureCache.viewport={zoom:l.zoom(),pan:l.pan(),width:o.canvasWidth,height:o.canvasHeight}).mpan={x:(0-k.pan.x)/k.zoom,y:(0-k.pan.y)/k.zoom}}c[o.DRAG]=!1,c[o.NODE]=!1;var _=u.contexts[o.NODE],M=o.textureCache.texture;k=o.textureCache.viewport;_.setTransform(1,0,0,1,0,0),h?P(_,0,0,k.width,k.height):_.clearRect(0,0,k.width,k.height);var B=m.core("outside-texture-bg-color").value,N=m.core("outside-texture-bg-opacity").value;o.colorFillStyle(_,B[0],B[1],B[2],N),_.fillRect(0,0,k.width,k.height);b=l.zoom();D(_,!1),_.clearRect(k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s),_.drawImage(M,k.mpan.x,k.mpan.y,k.width/k.zoom/s,k.height/k.zoom/s)}else o.textureOnViewport&&!t&&(o.textureCache=null);var z=l.extent(),I=o.pinching||o.hoverData.dragging||o.swipePanning||o.data.wheelZooming||o.hoverData.draggingEles||o.cy.animated(),A=o.hideEdgesOnViewport&&I,L=[];if(L[o.NODE]=!c[o.NODE]&&h&&!o.clearedForMotionBlur[o.NODE]||o.clearingMotionBlur,L[o.NODE]&&(o.clearedForMotionBlur[o.NODE]=!0),L[o.DRAG]=!c[o.DRAG]&&h&&!o.clearedForMotionBlur[o.DRAG]||o.clearingMotionBlur,L[o.DRAG]&&(o.clearedForMotionBlur[o.DRAG]=!0),c[o.NODE]||n||r||L[o.NODE]){var O=h&&!L[o.NODE]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_NODE]:u.contexts[o.NODE]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.nondrag,s,z):o.drawLayeredElements(_,S.nondrag,s,z),o.debug&&o.drawDebugPoints(_,S.nondrag),n||h||(c[o.NODE]=!1)}if(!r&&(c[o.DRAG]||n||L[o.DRAG])){O=h&&!L[o.DRAG]&&1!==p;D(_=t||(O?o.data.bufferContexts[o.MOTIONBLUR_BUFFER_DRAG]:u.contexts[o.DRAG]),h&&!O?"motionBlur":void 0),A?o.drawCachedNodes(_,S.drag,s,z):o.drawCachedElements(_,S.drag,s,z),o.debug&&o.drawDebugPoints(_,S.drag),n||h||(c[o.DRAG]=!1)}if(o.showFps||!r&&c[o.SELECT_BOX]&&!n){if(D(_=t||u.contexts[o.SELECT_BOX]),1==o.selection[4]&&(o.hoverData.selecting||o.touchData.selecting)){b=o.cy.zoom();var R=m.core("selection-box-border-width").value/b;_.lineWidth=R,_.fillStyle="rgba("+m.core("selection-box-color").value[0]+","+m.core("selection-box-color").value[1]+","+m.core("selection-box-color").value[2]+","+m.core("selection-box-opacity").value+")",_.fillRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]),R>0&&(_.strokeStyle="rgba("+m.core("selection-box-border-color").value[0]+","+m.core("selection-box-border-color").value[1]+","+m.core("selection-box-border-color").value[2]+","+m.core("selection-box-opacity").value+")",_.strokeRect(o.selection[0],o.selection[1],o.selection[2]-o.selection[0],o.selection[3]-o.selection[1]))}if(u.bgActivePosistion&&!o.hoverData.selecting){b=o.cy.zoom();var V=u.bgActivePosistion;_.fillStyle="rgba("+m.core("active-bg-color").value[0]+","+m.core("active-bg-color").value[1]+","+m.core("active-bg-color").value[2]+","+m.core("active-bg-opacity").value+")",_.beginPath(),_.arc(V.x,V.y,m.core("active-bg-size").pfValue/b,0,2*Math.PI),_.fill()}var F=o.lastRedrawTime;if(o.showFps&&F){F=Math.round(F);var j=Math.round(1e3/F);_.setTransform(1,0,0,1,0,0),_.fillStyle="rgba(255, 0, 0, 0.75)",_.strokeStyle="rgba(255, 0, 0, 0.75)",_.lineWidth=1,_.fillText("1 frame = "+F+" ms = "+j+" fps",0,20);_.strokeRect(0,30,250,20),_.fillRect(0,30,250*Math.min(j/60,1),20)}n||(c[o.SELECT_BOX]=!1)}if(h&&1!==p){var q=u.contexts[o.NODE],Y=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_NODE],X=u.contexts[o.DRAG],W=o.data.bufferCanvases[o.MOTIONBLUR_BUFFER_DRAG],H=function(e,t,n){e.setTransform(1,0,0,1,0,0),n||!y?e.clearRect(0,0,o.canvasWidth,o.canvasHeight):P(e,0,0,o.canvasWidth,o.canvasHeight);var r=p;e.drawImage(t,0,0,o.canvasWidth*r,o.canvasHeight*r,0,0,o.canvasWidth,o.canvasHeight)};(c[o.NODE]||L[o.NODE])&&(H(q,Y,L[o.NODE]),c[o.NODE]=!1),(c[o.DRAG]||L[o.DRAG])&&(H(X,W,L[o.DRAG]),c[o.DRAG]=!1)}o.prevViewport=k,o.clearingMotionBlur&&(o.clearingMotionBlur=!1,o.motionBlurCleared=!0,o.motionBlur=!0),h&&(o.motionBlurTimeout=setTimeout((function(){o.motionBlurTimeout=null,o.clearedForMotionBlur[o.NODE]=!1,o.clearedForMotionBlur[o.DRAG]=!1,o.motionBlur=!1,o.clearingMotionBlur=!d,o.mbFrames=0,c[o.NODE]=!0,c[o.DRAG]=!0,o.redraw()}),100)),t||l.emit("render")};for(var Qu={drawPolygonPath:function(e,t,n,r,i,a){var o=r/2,s=i/2;e.beginPath&&e.beginPath(),e.moveTo(t+o*a[0],n+s*a[1]);for(var l=1;l0&&a>0){h.clearRect(0,0,i,a),h.globalCompositeOperation="source-over";var p=this.getCachedZSortedEles();if(e.full)h.translate(-n.x1*l,-n.y1*l),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(n.x1*l,n.y1*l);else{var f=t.pan(),g={x:f.x*l,y:f.y*l};l*=t.zoom(),h.translate(g.x,g.y),h.scale(l,l),this.drawElements(h,p),h.scale(1/l,1/l),h.translate(-g.x,-g.y)}e.bg&&(h.globalCompositeOperation="destination-over",h.fillStyle=e.bg,h.rect(0,0,i,a),h.fill())}return d},ac.png=function(e){return sc(e,this.bufferCanvasImage(e),"image/png")},ac.jpg=function(e){return sc(e,this.bufferCanvasImage(e),"image/jpeg")};var lc={nodeShapeImpl:function(e,t,n,r,i,a,o,s){switch(e){case"ellipse":return this.drawEllipsePath(t,n,r,i,a);case"polygon":return this.drawPolygonPath(t,n,r,i,a,o);case"round-polygon":return this.drawRoundPolygonPath(t,n,r,i,a,o,s);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(t,n,r,i,a,s);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(t,n,r,i,a,o,s);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(t,n,r,i,a,s);case"barrel":return this.drawBarrelPath(t,n,r,i,a)}}},uc=dc,cc=dc.prototype;function dc(e){var t=this,n=t.cy.window().document;t.data={canvases:new Array(cc.CANVAS_LAYERS),contexts:new Array(cc.CANVAS_LAYERS),canvasNeedsRedraw:new Array(cc.CANVAS_LAYERS),bufferCanvases:new Array(cc.BUFFER_COUNT),bufferContexts:new Array(cc.CANVAS_LAYERS)};t.data.canvasContainer=n.createElement("div");var r=t.data.canvasContainer.style;t.data.canvasContainer.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)",r.position="relative",r.zIndex="0",r.overflow="hidden";var i=e.cy.container();i.appendChild(t.data.canvasContainer),i.style["-webkit-tap-highlight-color"]="rgba(0,0,0,0)";var a={"-webkit-user-select":"none","-moz-user-select":"-moz-none","user-select":"none","-webkit-tap-highlight-color":"rgba(0,0,0,0)","outline-style":"none"};c&&c.userAgent.match(/msie|trident|edge/i)&&(a["-ms-touch-action"]="none",a["touch-action"]="none");for(var o=0;o0;--i){entry=buckets[i].dequeue();if(entry){results=results.concat(removeNode(g,buckets,zeroIdx,entry,true));break}}}}return results}function removeNode(g,buckets,zeroIdx,entry,collectPredecessors){var results=collectPredecessors?[]:undefined;_.forEach(g.inEdges(entry.v),function(edge){var weight=g.edge(edge);var uEntry=g.node(edge.v);if(collectPredecessors){results.push({v:edge.v,w:edge.w})}uEntry.out-=weight;assignBucket(buckets,zeroIdx,uEntry)});_.forEach(g.outEdges(entry.v),function(edge){var weight=g.edge(edge);var w=edge.w;var wEntry=g.node(w);wEntry["in"]-=weight;assignBucket(buckets,zeroIdx,wEntry)});g.removeNode(entry.v);return results}function buildState(g,weightFn){var fasGraph=new Graph;var maxIn=0;var maxOut=0;_.forEach(g.nodes(),function(v){fasGraph.setNode(v,{v:v,in:0,out:0})}); +// Aggregate weights on nodes, but also sum the weights across multi-edges +// into a single edge for the fasGraph. +_.forEach(g.edges(),function(e){var prevWeight=fasGraph.edge(e.v,e.w)||0;var weight=weightFn(e);var edgeWeight=prevWeight+weight;fasGraph.setEdge(e.v,e.w,edgeWeight);maxOut=Math.max(maxOut,fasGraph.node(e.v).out+=weight);maxIn=Math.max(maxIn,fasGraph.node(e.w)["in"]+=weight)});var buckets=_.range(maxOut+maxIn+3).map(function(){return new List});var zeroIdx=maxIn+1;_.forEach(fasGraph.nodes(),function(v){assignBucket(buckets,zeroIdx,fasGraph.node(v))});return{graph:fasGraph,buckets:buckets,zeroIdx:zeroIdx}}function assignBucket(buckets,zeroIdx,entry){if(!entry.out){buckets[0].enqueue(entry)}else if(!entry["in"]){buckets[buckets.length-1].enqueue(entry)}else{buckets[entry.out-entry["in"]+zeroIdx].enqueue(entry)}}},{"./data/list":5,"./graphlib":7,"./lodash":10}],9:[function(require,module,exports){"use strict";var _=require("./lodash");var acyclic=require("./acyclic");var normalize=require("./normalize");var rank=require("./rank");var normalizeRanks=require("./util").normalizeRanks;var parentDummyChains=require("./parent-dummy-chains");var removeEmptyRanks=require("./util").removeEmptyRanks;var nestingGraph=require("./nesting-graph");var addBorderSegments=require("./add-border-segments");var coordinateSystem=require("./coordinate-system");var order=require("./order");var position=require("./position");var util=require("./util");var Graph=require("./graphlib").Graph;module.exports=layout;function layout(g,opts){var time=opts&&opts.debugTiming?util.time:util.notime;time("layout",function(){var layoutGraph=time(" buildLayoutGraph",function(){return buildLayoutGraph(g)});time(" runLayout",function(){runLayout(layoutGraph,time)});time(" updateInputGraph",function(){updateInputGraph(g,layoutGraph)})})}function runLayout(g,time){time(" makeSpaceForEdgeLabels",function(){makeSpaceForEdgeLabels(g)});time(" removeSelfEdges",function(){removeSelfEdges(g)});time(" acyclic",function(){acyclic.run(g)});time(" nestingGraph.run",function(){nestingGraph.run(g)});time(" rank",function(){rank(util.asNonCompoundGraph(g))});time(" injectEdgeLabelProxies",function(){injectEdgeLabelProxies(g)});time(" removeEmptyRanks",function(){removeEmptyRanks(g)});time(" nestingGraph.cleanup",function(){nestingGraph.cleanup(g)});time(" normalizeRanks",function(){normalizeRanks(g)});time(" assignRankMinMax",function(){assignRankMinMax(g)});time(" removeEdgeLabelProxies",function(){removeEdgeLabelProxies(g)});time(" normalize.run",function(){normalize.run(g)});time(" parentDummyChains",function(){parentDummyChains(g)});time(" addBorderSegments",function(){addBorderSegments(g)});time(" order",function(){order(g)});time(" insertSelfEdges",function(){insertSelfEdges(g)});time(" adjustCoordinateSystem",function(){coordinateSystem.adjust(g)});time(" position",function(){position(g)});time(" positionSelfEdges",function(){positionSelfEdges(g)});time(" removeBorderNodes",function(){removeBorderNodes(g)});time(" normalize.undo",function(){normalize.undo(g)});time(" fixupEdgeLabelCoords",function(){fixupEdgeLabelCoords(g)});time(" undoCoordinateSystem",function(){coordinateSystem.undo(g)});time(" translateGraph",function(){translateGraph(g)});time(" assignNodeIntersects",function(){assignNodeIntersects(g)});time(" reversePoints",function(){reversePointsForReversedEdges(g)});time(" acyclic.undo",function(){acyclic.undo(g)})} +/* + * Copies final layout information from the layout graph back to the input + * graph. This process only copies whitelisted attributes from the layout graph + * to the input graph, so it serves as a good place to determine what + * attributes can influence layout. + */function updateInputGraph(inputGraph,layoutGraph){_.forEach(inputGraph.nodes(),function(v){var inputLabel=inputGraph.node(v);var layoutLabel=layoutGraph.node(v);if(inputLabel){inputLabel.x=layoutLabel.x;inputLabel.y=layoutLabel.y;if(layoutGraph.children(v).length){inputLabel.width=layoutLabel.width;inputLabel.height=layoutLabel.height}}});_.forEach(inputGraph.edges(),function(e){var inputLabel=inputGraph.edge(e);var layoutLabel=layoutGraph.edge(e);inputLabel.points=layoutLabel.points;if(_.has(layoutLabel,"x")){inputLabel.x=layoutLabel.x;inputLabel.y=layoutLabel.y}});inputGraph.graph().width=layoutGraph.graph().width;inputGraph.graph().height=layoutGraph.graph().height}var graphNumAttrs=["nodesep","edgesep","ranksep","marginx","marginy"];var graphDefaults={ranksep:50,edgesep:20,nodesep:50,rankdir:"tb"};var graphAttrs=["acyclicer","ranker","rankdir","align"];var nodeNumAttrs=["width","height"];var nodeDefaults={width:0,height:0};var edgeNumAttrs=["minlen","weight","width","height","labeloffset"];var edgeDefaults={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:"r"};var edgeAttrs=["labelpos"]; +/* + * Constructs a new graph from the input graph, which can be used for layout. + * This process copies only whitelisted attributes from the input graph to the + * layout graph. Thus this function serves as a good place to determine what + * attributes can influence layout. + */function buildLayoutGraph(inputGraph){var g=new Graph({multigraph:true,compound:true});var graph=canonicalize(inputGraph.graph());g.setGraph(_.merge({},graphDefaults,selectNumberAttrs(graph,graphNumAttrs),_.pick(graph,graphAttrs)));_.forEach(inputGraph.nodes(),function(v){var node=canonicalize(inputGraph.node(v));g.setNode(v,_.defaults(selectNumberAttrs(node,nodeNumAttrs),nodeDefaults));g.setParent(v,inputGraph.parent(v))});_.forEach(inputGraph.edges(),function(e){var edge=canonicalize(inputGraph.edge(e));g.setEdge(e,_.merge({},edgeDefaults,selectNumberAttrs(edge,edgeNumAttrs),_.pick(edge,edgeAttrs)))});return g} +/* + * This idea comes from the Gansner paper: to account for edge labels in our + * layout we split each rank in half by doubling minlen and halving ranksep. + * Then we can place labels at these mid-points between nodes. + * + * We also add some minimal padding to the width to push the label for the edge + * away from the edge itself a bit. + */function makeSpaceForEdgeLabels(g){var graph=g.graph();graph.ranksep/=2;_.forEach(g.edges(),function(e){var edge=g.edge(e);edge.minlen*=2;if(edge.labelpos.toLowerCase()!=="c"){if(graph.rankdir==="TB"||graph.rankdir==="BT"){edge.width+=edge.labeloffset}else{edge.height+=edge.labeloffset}}})} +/* + * Creates temporary dummy nodes that capture the rank in which each edge's + * label is going to, if it has one of non-zero width and height. We do this + * so that we can safely remove empty ranks while preserving balance for the + * label's position. + */function injectEdgeLabelProxies(g){_.forEach(g.edges(),function(e){var edge=g.edge(e);if(edge.width&&edge.height){var v=g.node(e.v);var w=g.node(e.w);var label={rank:(w.rank-v.rank)/2+v.rank,e:e};util.addDummyNode(g,"edge-proxy",label,"_ep")}})}function assignRankMinMax(g){var maxRank=0;_.forEach(g.nodes(),function(v){var node=g.node(v);if(node.borderTop){node.minRank=g.node(node.borderTop).rank;node.maxRank=g.node(node.borderBottom).rank;maxRank=_.max(maxRank,node.maxRank)}});g.graph().maxRank=maxRank}function removeEdgeLabelProxies(g){_.forEach(g.nodes(),function(v){var node=g.node(v);if(node.dummy==="edge-proxy"){g.edge(node.e).labelRank=node.rank;g.removeNode(v)}})}function translateGraph(g){var minX=Number.POSITIVE_INFINITY;var maxX=0;var minY=Number.POSITIVE_INFINITY;var maxY=0;var graphLabel=g.graph();var marginX=graphLabel.marginx||0;var marginY=graphLabel.marginy||0;function getExtremes(attrs){var x=attrs.x;var y=attrs.y;var w=attrs.width;var h=attrs.height;minX=Math.min(minX,x-w/2);maxX=Math.max(maxX,x+w/2);minY=Math.min(minY,y-h/2);maxY=Math.max(maxY,y+h/2)}_.forEach(g.nodes(),function(v){getExtremes(g.node(v))});_.forEach(g.edges(),function(e){var edge=g.edge(e);if(_.has(edge,"x")){getExtremes(edge)}});minX-=marginX;minY-=marginY;_.forEach(g.nodes(),function(v){var node=g.node(v);node.x-=minX;node.y-=minY});_.forEach(g.edges(),function(e){var edge=g.edge(e);_.forEach(edge.points,function(p){p.x-=minX;p.y-=minY});if(_.has(edge,"x")){edge.x-=minX}if(_.has(edge,"y")){edge.y-=minY}});graphLabel.width=maxX-minX+marginX;graphLabel.height=maxY-minY+marginY}function assignNodeIntersects(g){_.forEach(g.edges(),function(e){var edge=g.edge(e);var nodeV=g.node(e.v);var nodeW=g.node(e.w);var p1,p2;if(!edge.points){edge.points=[];p1=nodeW;p2=nodeV}else{p1=edge.points[0];p2=edge.points[edge.points.length-1]}edge.points.unshift(util.intersectRect(nodeV,p1));edge.points.push(util.intersectRect(nodeW,p2))})}function fixupEdgeLabelCoords(g){_.forEach(g.edges(),function(e){var edge=g.edge(e);if(_.has(edge,"x")){if(edge.labelpos==="l"||edge.labelpos==="r"){edge.width-=edge.labeloffset}switch(edge.labelpos){case"l":edge.x-=edge.width/2+edge.labeloffset;break;case"r":edge.x+=edge.width/2+edge.labeloffset;break}}})}function reversePointsForReversedEdges(g){_.forEach(g.edges(),function(e){var edge=g.edge(e);if(edge.reversed){edge.points.reverse()}})}function removeBorderNodes(g){_.forEach(g.nodes(),function(v){if(g.children(v).length){var node=g.node(v);var t=g.node(node.borderTop);var b=g.node(node.borderBottom);var l=g.node(_.last(node.borderLeft));var r=g.node(_.last(node.borderRight));node.width=Math.abs(r.x-l.x);node.height=Math.abs(b.y-t.y);node.x=l.x+node.width/2;node.y=t.y+node.height/2}});_.forEach(g.nodes(),function(v){if(g.node(v).dummy==="border"){g.removeNode(v)}})}function removeSelfEdges(g){_.forEach(g.edges(),function(e){if(e.v===e.w){var node=g.node(e.v);if(!node.selfEdges){node.selfEdges=[]}node.selfEdges.push({e:e,label:g.edge(e)});g.removeEdge(e)}})}function insertSelfEdges(g){var layers=util.buildLayerMatrix(g);_.forEach(layers,function(layer){var orderShift=0;_.forEach(layer,function(v,i){var node=g.node(v);node.order=i+orderShift;_.forEach(node.selfEdges,function(selfEdge){util.addDummyNode(g,"selfedge",{width:selfEdge.label.width,height:selfEdge.label.height,rank:node.rank,order:i+ ++orderShift,e:selfEdge.e,label:selfEdge.label},"_se")});delete node.selfEdges})})}function positionSelfEdges(g){_.forEach(g.nodes(),function(v){var node=g.node(v);if(node.dummy==="selfedge"){var selfNode=g.node(node.e.v);var x=selfNode.x+selfNode.width/2;var y=selfNode.y;var dx=node.x-x;var dy=selfNode.height/2;g.setEdge(node.e,node.label);g.removeNode(v);node.label.points=[{x:x+2*dx/3,y:y-dy},{x:x+5*dx/6,y:y-dy},{x:x+dx,y:y},{x:x+5*dx/6,y:y+dy},{x:x+2*dx/3,y:y+dy}];node.label.x=node.x;node.label.y=node.y}})}function selectNumberAttrs(obj,attrs){return _.mapValues(_.pick(obj,attrs),Number)}function canonicalize(attrs){var newAttrs={};_.forEach(attrs,function(v,k){newAttrs[k.toLowerCase()]=v});return newAttrs}},{"./acyclic":2,"./add-border-segments":3,"./coordinate-system":4,"./graphlib":7,"./lodash":10,"./nesting-graph":11,"./normalize":12,"./order":17,"./parent-dummy-chains":22,"./position":24,"./rank":26,"./util":29}],10:[function(require,module,exports){ +/* global window */ +var lodash;if(typeof require==="function"){try{lodash={cloneDeep:require("lodash/cloneDeep"),constant:require("lodash/constant"),defaults:require("lodash/defaults"),each:require("lodash/each"),filter:require("lodash/filter"),find:require("lodash/find"),flatten:require("lodash/flatten"),forEach:require("lodash/forEach"),forIn:require("lodash/forIn"),has:require("lodash/has"),isUndefined:require("lodash/isUndefined"),last:require("lodash/last"),map:require("lodash/map"),mapValues:require("lodash/mapValues"),max:require("lodash/max"),merge:require("lodash/merge"),min:require("lodash/min"),minBy:require("lodash/minBy"),now:require("lodash/now"),pick:require("lodash/pick"),range:require("lodash/range"),reduce:require("lodash/reduce"),sortBy:require("lodash/sortBy"),uniqueId:require("lodash/uniqueId"),values:require("lodash/values"),zipObject:require("lodash/zipObject")}}catch(e){ +// continue regardless of error +}}if(!lodash){lodash=window._}module.exports=lodash},{"lodash/cloneDeep":227,"lodash/constant":228,"lodash/defaults":229,"lodash/each":230,"lodash/filter":232,"lodash/find":233,"lodash/flatten":235,"lodash/forEach":236,"lodash/forIn":237,"lodash/has":239,"lodash/isUndefined":258,"lodash/last":261,"lodash/map":262,"lodash/mapValues":263,"lodash/max":264,"lodash/merge":266,"lodash/min":267,"lodash/minBy":268,"lodash/now":270,"lodash/pick":271,"lodash/range":273,"lodash/reduce":274,"lodash/sortBy":276,"lodash/uniqueId":286,"lodash/values":287,"lodash/zipObject":288}],11:[function(require,module,exports){var _=require("./lodash");var util=require("./util");module.exports={run:run,cleanup:cleanup}; +/* + * A nesting graph creates dummy nodes for the tops and bottoms of subgraphs, + * adds appropriate edges to ensure that all cluster nodes are placed between + * these boundries, and ensures that the graph is connected. + * + * In addition we ensure, through the use of the minlen property, that nodes + * and subgraph border nodes to not end up on the same rank. + * + * Preconditions: + * + * 1. Input graph is a DAG + * 2. Nodes in the input graph has a minlen attribute + * + * Postconditions: + * + * 1. Input graph is connected. + * 2. Dummy nodes are added for the tops and bottoms of subgraphs. + * 3. The minlen attribute for nodes is adjusted to ensure nodes do not + * get placed on the same rank as subgraph border nodes. + * + * The nesting graph idea comes from Sander, "Layout of Compound Directed + * Graphs." + */function run(g){var root=util.addDummyNode(g,"root",{},"_root");var depths=treeDepths(g);var height=_.max(_.values(depths))-1;// Note: depths is an Object not an array +var nodeSep=2*height+1;g.graph().nestingRoot=root; +// Multiply minlen by nodeSep to align nodes on non-border ranks. +_.forEach(g.edges(),function(e){g.edge(e).minlen*=nodeSep}); +// Calculate a weight that is sufficient to keep subgraphs vertically compact +var weight=sumWeights(g)+1; +// Create border nodes and link them up +_.forEach(g.children(),function(child){dfs(g,root,nodeSep,weight,height,depths,child)}); +// Save the multiplier for node layers for later removal of empty border +// layers. +g.graph().nodeRankFactor=nodeSep}function dfs(g,root,nodeSep,weight,height,depths,v){var children=g.children(v);if(!children.length){if(v!==root){g.setEdge(root,v,{weight:0,minlen:nodeSep})}return}var top=util.addBorderNode(g,"_bt");var bottom=util.addBorderNode(g,"_bb");var label=g.node(v);g.setParent(top,v);label.borderTop=top;g.setParent(bottom,v);label.borderBottom=bottom;_.forEach(children,function(child){dfs(g,root,nodeSep,weight,height,depths,child);var childNode=g.node(child);var childTop=childNode.borderTop?childNode.borderTop:child;var childBottom=childNode.borderBottom?childNode.borderBottom:child;var thisWeight=childNode.borderTop?weight:2*weight;var minlen=childTop!==childBottom?1:height-depths[v]+1;g.setEdge(top,childTop,{weight:thisWeight,minlen:minlen,nestingEdge:true});g.setEdge(childBottom,bottom,{weight:thisWeight,minlen:minlen,nestingEdge:true})});if(!g.parent(v)){g.setEdge(root,top,{weight:0,minlen:height+depths[v]})}}function treeDepths(g){var depths={};function dfs(v,depth){var children=g.children(v);if(children&&children.length){_.forEach(children,function(child){dfs(child,depth+1)})}depths[v]=depth}_.forEach(g.children(),function(v){dfs(v,1)});return depths}function sumWeights(g){return _.reduce(g.edges(),function(acc,e){return acc+g.edge(e).weight},0)}function cleanup(g){var graphLabel=g.graph();g.removeNode(graphLabel.nestingRoot);delete graphLabel.nestingRoot;_.forEach(g.edges(),function(e){var edge=g.edge(e);if(edge.nestingEdge){g.removeEdge(e)}})}},{"./lodash":10,"./util":29}],12:[function(require,module,exports){"use strict";var _=require("./lodash");var util=require("./util");module.exports={run:run,undo:undo}; +/* + * Breaks any long edges in the graph into short segments that span 1 layer + * each. This operation is undoable with the denormalize function. + * + * Pre-conditions: + * + * 1. The input graph is a DAG. + * 2. Each node in the graph has a "rank" property. + * + * Post-condition: + * + * 1. All edges in the graph have a length of 1. + * 2. Dummy nodes are added where edges have been split into segments. + * 3. The graph is augmented with a "dummyChains" attribute which contains + * the first dummy in each chain of dummy nodes produced. + */function run(g){g.graph().dummyChains=[];_.forEach(g.edges(),function(edge){normalizeEdge(g,edge)})}function normalizeEdge(g,e){var v=e.v;var vRank=g.node(v).rank;var w=e.w;var wRank=g.node(w).rank;var name=e.name;var edgeLabel=g.edge(e);var labelRank=edgeLabel.labelRank;if(wRank===vRank+1)return;g.removeEdge(e);var dummy,attrs,i;for(i=0,++vRank;vRank0){if(index%2){weightSum+=tree[index+1]}index=index-1>>1;tree[index]+=entry.weight}cc+=entry.weight*weightSum}));return cc}},{"../lodash":10}],17:[function(require,module,exports){"use strict";var _=require("../lodash");var initOrder=require("./init-order");var crossCount=require("./cross-count");var sortSubgraph=require("./sort-subgraph");var buildLayerGraph=require("./build-layer-graph");var addSubgraphConstraints=require("./add-subgraph-constraints");var Graph=require("../graphlib").Graph;var util=require("../util");module.exports=order; +/* + * Applies heuristics to minimize edge crossings in the graph and sets the best + * order solution as an order attribute on each node. + * + * Pre-conditions: + * + * 1. Graph must be DAG + * 2. Graph nodes must be objects with a "rank" attribute + * 3. Graph edges must have the "weight" attribute + * + * Post-conditions: + * + * 1. Graph nodes will have an "order" attribute based on the results of the + * algorithm. + */function order(g){var maxRank=util.maxRank(g),downLayerGraphs=buildLayerGraphs(g,_.range(1,maxRank+1),"inEdges"),upLayerGraphs=buildLayerGraphs(g,_.range(maxRank-1,-1,-1),"outEdges");var layering=initOrder(g);assignOrder(g,layering);var bestCC=Number.POSITIVE_INFINITY,best;for(var i=0,lastBest=0;lastBest<4;++i,++lastBest){sweepLayerGraphs(i%2?downLayerGraphs:upLayerGraphs,i%4>=2);layering=util.buildLayerMatrix(g);var cc=crossCount(g,layering);if(cc=vEntry.barycenter){mergeEntries(vEntry,uEntry)}}}function handleOut(vEntry){return function(wEntry){wEntry["in"].push(vEntry);if(--wEntry.indegree===0){sourceSet.push(wEntry)}}}while(sourceSet.length){var entry=sourceSet.pop();entries.push(entry);_.forEach(entry["in"].reverse(),handleIn(entry));_.forEach(entry.out,handleOut(entry))}return _.map(_.filter(entries,function(entry){return!entry.merged}),function(entry){return _.pick(entry,["vs","i","barycenter","weight"])})}function mergeEntries(target,source){var sum=0;var weight=0;if(target.weight){sum+=target.barycenter*target.weight;weight+=target.weight}if(source.weight){sum+=source.barycenter*source.weight;weight+=source.weight}target.vs=source.vs.concat(target.vs);target.barycenter=sum/weight;target.weight=weight;target.i=Math.min(source.i,target.i);source.merged=true}},{"../lodash":10}],20:[function(require,module,exports){var _=require("../lodash");var barycenter=require("./barycenter");var resolveConflicts=require("./resolve-conflicts");var sort=require("./sort");module.exports=sortSubgraph;function sortSubgraph(g,v,cg,biasRight){var movable=g.children(v);var node=g.node(v);var bl=node?node.borderLeft:undefined;var br=node?node.borderRight:undefined;var subgraphs={};if(bl){movable=_.filter(movable,function(w){return w!==bl&&w!==br})}var barycenters=barycenter(g,movable);_.forEach(barycenters,function(entry){if(g.children(entry.v).length){var subgraphResult=sortSubgraph(g,entry.v,cg,biasRight);subgraphs[entry.v]=subgraphResult;if(_.has(subgraphResult,"barycenter")){mergeBarycenters(entry,subgraphResult)}}});var entries=resolveConflicts(barycenters,cg);expandSubgraphs(entries,subgraphs);var result=sort(entries,biasRight);if(bl){result.vs=_.flatten([bl,result.vs,br],true);if(g.predecessors(bl).length){var blPred=g.node(g.predecessors(bl)[0]),brPred=g.node(g.predecessors(br)[0]);if(!_.has(result,"barycenter")){result.barycenter=0;result.weight=0}result.barycenter=(result.barycenter*result.weight+blPred.order+brPred.order)/(result.weight+2);result.weight+=2}}return result}function expandSubgraphs(entries,subgraphs){_.forEach(entries,function(entry){entry.vs=_.flatten(entry.vs.map(function(v){if(subgraphs[v]){return subgraphs[v].vs}return v}),true)})}function mergeBarycenters(target,other){if(!_.isUndefined(target.barycenter)){target.barycenter=(target.barycenter*target.weight+other.barycenter*other.weight)/(target.weight+other.weight);target.weight+=other.weight}else{target.barycenter=other.barycenter;target.weight=other.weight}}},{"../lodash":10,"./barycenter":14,"./resolve-conflicts":19,"./sort":21}],21:[function(require,module,exports){var _=require("../lodash");var util=require("../util");module.exports=sort;function sort(entries,biasRight){var parts=util.partition(entries,function(entry){return _.has(entry,"barycenter")});var sortable=parts.lhs,unsortable=_.sortBy(parts.rhs,function(entry){return-entry.i}),vs=[],sum=0,weight=0,vsIndex=0;sortable.sort(compareWithBias(!!biasRight));vsIndex=consumeUnsortable(vs,unsortable,vsIndex);_.forEach(sortable,function(entry){vsIndex+=entry.vs.length;vs.push(entry.vs);sum+=entry.barycenter*entry.weight;weight+=entry.weight;vsIndex=consumeUnsortable(vs,unsortable,vsIndex)});var result={vs:_.flatten(vs,true)};if(weight){result.barycenter=sum/weight;result.weight=weight}return result}function consumeUnsortable(vs,unsortable,index){var last;while(unsortable.length&&(last=_.last(unsortable)).i<=index){unsortable.pop();vs.push(last.vs);index++}return index}function compareWithBias(bias){return function(entryV,entryW){if(entryV.barycenterentryW.barycenter){return 1}return!bias?entryV.i-entryW.i:entryW.i-entryV.i}}},{"../lodash":10,"../util":29}],22:[function(require,module,exports){var _=require("./lodash");module.exports=parentDummyChains;function parentDummyChains(g){var postorderNums=postorder(g);_.forEach(g.graph().dummyChains,function(v){var node=g.node(v);var edgeObj=node.edgeObj;var pathData=findPath(g,postorderNums,edgeObj.v,edgeObj.w);var path=pathData.path;var lca=pathData.lca;var pathIdx=0;var pathV=path[pathIdx];var ascending=true;while(v!==edgeObj.w){node=g.node(v);if(ascending){while((pathV=path[pathIdx])!==lca&&g.node(pathV).maxRanklow||lim>postorderNums[parent].lim));lca=parent; +// Traverse from w to LCA +parent=w;while((parent=g.parent(parent))!==lca){wPath.push(parent)}return{path:vPath.concat(wPath.reverse()),lca:lca}}function postorder(g){var result={};var lim=0;function dfs(v){var low=lim;_.forEach(g.children(v),dfs);result[v]={low:low,lim:lim++}}_.forEach(g.children(),dfs);return result}},{"./lodash":10}],23:[function(require,module,exports){"use strict";var _=require("../lodash");var Graph=require("../graphlib").Graph;var util=require("../util"); +/* + * This module provides coordinate assignment based on Brandes and Köpf, "Fast + * and Simple Horizontal Coordinate Assignment." + */module.exports={positionX:positionX,findType1Conflicts:findType1Conflicts,findType2Conflicts:findType2Conflicts,addConflict:addConflict,hasConflict:hasConflict,verticalAlignment:verticalAlignment,horizontalCompaction:horizontalCompaction,alignCoordinates:alignCoordinates,findSmallestWidthAlignment:findSmallestWidthAlignment,balance:balance}; +/* + * Marks all edges in the graph with a type-1 conflict with the "type1Conflict" + * property. A type-1 conflict is one where a non-inner segment crosses an + * inner segment. An inner segment is an edge with both incident nodes marked + * with the "dummy" property. + * + * This algorithm scans layer by layer, starting with the second, for type-1 + * conflicts between the current layer and the previous layer. For each layer + * it scans the nodes from left to right until it reaches one that is incident + * on an inner segment. It then scans predecessors to determine if they have + * edges that cross that inner segment. At the end a final scan is done for all + * nodes on the current rank to see if they cross the last visited inner + * segment. + * + * This algorithm (safely) assumes that a dummy node will only be incident on a + * single node in the layers being scanned. + */function findType1Conflicts(g,layering){var conflicts={};function visitLayer(prevLayer,layer){var +// last visited node in the previous layer that is incident on an inner +// segment. +k0=0, +// Tracks the last node in this layer scanned for crossings with a type-1 +// segment. +scanPos=0,prevLayerLength=prevLayer.length,lastNode=_.last(layer);_.forEach(layer,function(v,i){var w=findOtherInnerSegmentNode(g,v),k1=w?g.node(w).order:prevLayerLength;if(w||v===lastNode){_.forEach(layer.slice(scanPos,i+1),function(scanNode){_.forEach(g.predecessors(scanNode),function(u){var uLabel=g.node(u),uPos=uLabel.order;if((uPosnextNorthBorder)){addConflict(conflicts,u,v)}})}})}function visitLayer(north,south){var prevNorthPos=-1,nextNorthPos,southPos=0;_.forEach(south,function(v,southLookahead){if(g.node(v).dummy==="border"){var predecessors=g.predecessors(v);if(predecessors.length){nextNorthPos=g.node(predecessors[0]).order;scan(south,southPos,southLookahead,prevNorthPos,nextNorthPos);southPos=southLookahead;prevNorthPos=nextNorthPos}}scan(south,southPos,south.length,nextNorthPos,north.length)});return south}_.reduce(layering,visitLayer);return conflicts}function findOtherInnerSegmentNode(g,v){if(g.node(v).dummy){return _.find(g.predecessors(v),function(u){return g.node(u).dummy})}}function addConflict(conflicts,v,w){if(v>w){var tmp=v;v=w;w=tmp}var conflictsV=conflicts[v];if(!conflictsV){conflicts[v]=conflictsV={}}conflictsV[w]=true}function hasConflict(conflicts,v,w){if(v>w){var tmp=v;v=w;w=tmp}return _.has(conflicts[v],w)} +/* + * Try to align nodes into vertical "blocks" where possible. This algorithm + * attempts to align a node with one of its median neighbors. If the edge + * connecting a neighbor is a type-1 conflict then we ignore that possibility. + * If a previous node has already formed a block with a node after the node + * we're trying to form a block with, we also ignore that possibility - our + * blocks would be split in that scenario. + */function verticalAlignment(g,layering,conflicts,neighborFn){var root={},align={},pos={}; +// We cache the position here based on the layering because the graph and +// layering may be out of sync. The layering matrix is manipulated to +// generate different extreme alignments. +_.forEach(layering,function(layer){_.forEach(layer,function(v,order){root[v]=v;align[v]=v;pos[v]=order})});_.forEach(layering,function(layer){var prevIdx=-1;_.forEach(layer,function(v){var ws=neighborFn(v);if(ws.length){ws=_.sortBy(ws,function(w){return pos[w]});var mp=(ws.length-1)/2;for(var i=Math.floor(mp),il=Math.ceil(mp);i<=il;++i){var w=ws[i];if(align[v]===v&&prevIdxwLabel.lim){tailLabel=wLabel;flip=true}var candidates=_.filter(g.edges(),function(edge){return flip===isDescendant(t,t.node(edge.v),tailLabel)&&flip!==isDescendant(t,t.node(edge.w),tailLabel)});return _.minBy(candidates,function(edge){return slack(g,edge)})}function exchangeEdges(t,g,e,f){var v=e.v;var w=e.w;t.removeEdge(v,w);t.setEdge(f.v,f.w,{});initLowLimValues(t);initCutValues(t,g);updateRanks(t,g)}function updateRanks(t,g){var root=_.find(t.nodes(),function(v){return!g.node(v).parent});var vs=preorder(t,root);vs=vs.slice(1);_.forEach(vs,function(v){var parent=t.node(v).parent,edge=g.edge(v,parent),flipped=false;if(!edge){edge=g.edge(parent,v);flipped=true}g.node(v).rank=g.node(parent).rank+(flipped?edge.minlen:-edge.minlen)})} +/* + * Returns true if the edge is in the tree. + */function isTreeEdge(tree,u,v){return tree.hasEdge(u,v)} +/* + * Returns true if the specified node is descendant of the root node per the + * assigned low and lim attributes in the tree. + */function isDescendant(tree,vLabel,rootLabel){return rootLabel.low<=vLabel.lim&&vLabel.lim<=rootLabel.lim}},{"../graphlib":7,"../lodash":10,"../util":29,"./feasible-tree":25,"./util":28}],28:[function(require,module,exports){"use strict";var _=require("../lodash");module.exports={longestPath:longestPath,slack:slack}; +/* + * Initializes ranks for the input graph using the longest path algorithm. This + * algorithm scales well and is fast in practice, it yields rather poor + * solutions. Nodes are pushed to the lowest layer possible, leaving the bottom + * ranks wide and leaving edges longer than necessary. However, due to its + * speed, this algorithm is good for getting an initial ranking that can be fed + * into other algorithms. + * + * This algorithm does not normalize layers because it will be used by other + * algorithms in most cases. If using this algorithm directly, be sure to + * run normalize at the end. + * + * Pre-conditions: + * + * 1. Input graph is a DAG. + * 2. Input graph node labels can be assigned properties. + * + * Post-conditions: + * + * 1. Each node will be assign an (unnormalized) "rank" property. + */function longestPath(g){var visited={};function dfs(v){var label=g.node(v);if(_.has(visited,v)){return label.rank}visited[v]=true;var rank=_.min(_.map(g.outEdges(v),function(e){return dfs(e.w)-g.edge(e).minlen}));if(rank===Number.POSITIVE_INFINITY||// return value of _.map([]) for Lodash 3 +rank===undefined||// return value of _.map([]) for Lodash 4 +rank===null){// return value of _.map([null]) +rank=0}return label.rank=rank}_.forEach(g.sources(),dfs)} +/* + * Returns the amount of slack for the given edge. The slack is defined as the + * difference between the length of the edge and its minimum length. + */function slack(g,e){return g.node(e.w).rank-g.node(e.v).rank-g.edge(e).minlen}},{"../lodash":10}],29:[function(require,module,exports){ +/* eslint "no-console": off */ +"use strict";var _=require("./lodash");var Graph=require("./graphlib").Graph;module.exports={addDummyNode:addDummyNode,simplify:simplify,asNonCompoundGraph:asNonCompoundGraph,successorWeights:successorWeights,predecessorWeights:predecessorWeights,intersectRect:intersectRect,buildLayerMatrix:buildLayerMatrix,normalizeRanks:normalizeRanks,removeEmptyRanks:removeEmptyRanks,addBorderNode:addBorderNode,maxRank:maxRank,partition:partition,time:time,notime:notime}; +/* + * Adds a dummy node to the graph and return v. + */function addDummyNode(g,type,attrs,name){var v;do{v=_.uniqueId(name)}while(g.hasNode(v));attrs.dummy=type;g.setNode(v,attrs);return v} +/* + * Returns a new graph with only simple edges. Handles aggregation of data + * associated with multi-edges. + */function simplify(g){var simplified=(new Graph).setGraph(g.graph());_.forEach(g.nodes(),function(v){simplified.setNode(v,g.node(v))});_.forEach(g.edges(),function(e){var simpleLabel=simplified.edge(e.v,e.w)||{weight:0,minlen:1};var label=g.edge(e);simplified.setEdge(e.v,e.w,{weight:simpleLabel.weight+label.weight,minlen:Math.max(simpleLabel.minlen,label.minlen)})});return simplified}function asNonCompoundGraph(g){var simplified=new Graph({multigraph:g.isMultigraph()}).setGraph(g.graph());_.forEach(g.nodes(),function(v){if(!g.children(v).length){simplified.setNode(v,g.node(v))}});_.forEach(g.edges(),function(e){simplified.setEdge(e,g.edge(e))});return simplified}function successorWeights(g){var weightMap=_.map(g.nodes(),function(v){var sucs={};_.forEach(g.outEdges(v),function(e){sucs[e.w]=(sucs[e.w]||0)+g.edge(e).weight});return sucs});return _.zipObject(g.nodes(),weightMap)}function predecessorWeights(g){var weightMap=_.map(g.nodes(),function(v){var preds={};_.forEach(g.inEdges(v),function(e){preds[e.v]=(preds[e.v]||0)+g.edge(e).weight});return preds});return _.zipObject(g.nodes(),weightMap)} +/* + * Finds where a line starting at point ({x, y}) would intersect a rectangle + * ({x, y, width, height}) if it were pointing at the rectangle's center. + */function intersectRect(rect,point){var x=rect.x;var y=rect.y; +// Rectangle intersection algorithm from: +// http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes +var dx=point.x-x;var dy=point.y-y;var w=rect.width/2;var h=rect.height/2;if(!dx&&!dy){throw new Error("Not possible to find intersection inside of the rectangle")}var sx,sy;if(Math.abs(dy)*w>Math.abs(dx)*h){ +// Intersection is top or bottom of rect. +if(dy<0){h=-h}sx=h*dx/dy;sy=h}else{ +// Intersection is left or right of rect. +if(dx<0){w=-w}sx=w;sy=w*dy/dx}return{x:x+sx,y:y+sy}} +/* + * Given a DAG with each node assigned "rank" and "order" properties, this + * function will produce a matrix with the ids of each node. + */function buildLayerMatrix(g){var layering=_.map(_.range(maxRank(g)+1),function(){return[]});_.forEach(g.nodes(),function(v){var node=g.node(v);var rank=node.rank;if(!_.isUndefined(rank)){layering[rank][node.order]=v}});return layering} +/* + * Adjusts the ranks for all nodes in the graph such that all nodes v have + * rank(v) >= 0 and at least one node w has rank(w) = 0. + */function normalizeRanks(g){var min=_.min(_.map(g.nodes(),function(v){return g.node(v).rank}));_.forEach(g.nodes(),function(v){var node=g.node(v);if(_.has(node,"rank")){node.rank-=min}})}function removeEmptyRanks(g){ +// Ranks may not start at 0, so we need to offset them +var offset=_.min(_.map(g.nodes(),function(v){return g.node(v).rank}));var layers=[];_.forEach(g.nodes(),function(v){var rank=g.node(v).rank-offset;if(!layers[rank]){layers[rank]=[]}layers[rank].push(v)});var delta=0;var nodeRankFactor=g.graph().nodeRankFactor;_.forEach(layers,function(vs,i){if(_.isUndefined(vs)&&i%nodeRankFactor!==0){--delta}else if(delta){_.forEach(vs,function(v){g.node(v).rank+=delta})}})}function addBorderNode(g,prefix,rank,order){var node={width:0,height:0};if(arguments.length>=4){node.rank=rank;node.order=order}return addDummyNode(g,"border",node,prefix)}function maxRank(g){return _.max(_.map(g.nodes(),function(v){var rank=g.node(v).rank;if(!_.isUndefined(rank)){return rank}}))} +/* + * Partition a collection into two groups: `lhs` and `rhs`. If the supplied + * function returns true for an entry it goes into `lhs`. Otherwise it goes + * into `rhs. + */function partition(collection,fn){var result={lhs:[],rhs:[]};_.forEach(collection,function(value){if(fn(value)){result.lhs.push(value)}else{result.rhs.push(value)}});return result} +/* + * Returns a new function that wraps `fn` with a timer. The wrapper logs the + * time it takes to execute the function. + */function time(name,fn){var start=_.now();try{return fn()}finally{console.log(name+" time: "+(_.now()-start)+"ms")}}function notime(name,fn){return fn()}},{"./graphlib":7,"./lodash":10}],30:[function(require,module,exports){module.exports="0.8.5"},{}],31:[function(require,module,exports){ +/** + * Copyright (c) 2014, Chris Pettitt + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +var lib=require("./lib");module.exports={Graph:lib.Graph,json:require("./lib/json"),alg:require("./lib/alg"),version:lib.version}},{"./lib":47,"./lib/alg":38,"./lib/json":48}],32:[function(require,module,exports){var _=require("../lodash");module.exports=components;function components(g){var visited={};var cmpts=[];var cmpt;function dfs(v){if(_.has(visited,v))return;visited[v]=true;cmpt.push(v);_.each(g.successors(v),dfs);_.each(g.predecessors(v),dfs)}_.each(g.nodes(),function(v){cmpt=[];dfs(v);if(cmpt.length){cmpts.push(cmpt)}});return cmpts}},{"../lodash":49}],33:[function(require,module,exports){var _=require("../lodash");module.exports=dfs; +/* + * A helper that preforms a pre- or post-order traversal on the input graph + * and returns the nodes in the order they were visited. If the graph is + * undirected then this algorithm will navigate using neighbors. If the graph + * is directed then this algorithm will navigate using successors. + * + * Order must be one of "pre" or "post". + */function dfs(g,vs,order){if(!_.isArray(vs)){vs=[vs]}var navigation=(g.isDirected()?g.successors:g.neighbors).bind(g);var acc=[];var visited={};_.each(vs,function(v){if(!g.hasNode(v)){throw new Error("Graph does not have node: "+v)}doDfs(g,v,order==="post",visited,navigation,acc)});return acc}function doDfs(g,v,postorder,visited,navigation,acc){if(!_.has(visited,v)){visited[v]=true;if(!postorder){acc.push(v)}_.each(navigation(v),function(w){doDfs(g,w,postorder,visited,navigation,acc)});if(postorder){acc.push(v)}}}},{"../lodash":49}],34:[function(require,module,exports){var dijkstra=require("./dijkstra");var _=require("../lodash");module.exports=dijkstraAll;function dijkstraAll(g,weightFunc,edgeFunc){return _.transform(g.nodes(),function(acc,v){acc[v]=dijkstra(g,v,weightFunc,edgeFunc)},{})}},{"../lodash":49,"./dijkstra":35}],35:[function(require,module,exports){var _=require("../lodash");var PriorityQueue=require("../data/priority-queue");module.exports=dijkstra;var DEFAULT_WEIGHT_FUNC=_.constant(1);function dijkstra(g,source,weightFn,edgeFn){return runDijkstra(g,String(source),weightFn||DEFAULT_WEIGHT_FUNC,edgeFn||function(v){return g.outEdges(v)})}function runDijkstra(g,source,weightFn,edgeFn){var results={};var pq=new PriorityQueue;var v,vEntry;var updateNeighbors=function(edge){var w=edge.v!==v?edge.v:edge.w;var wEntry=results[w];var weight=weightFn(edge);var distance=vEntry.distance+weight;if(weight<0){throw new Error("dijkstra does not allow negative edge weights. "+"Bad edge: "+edge+" Weight: "+weight)}if(distance0){v=pq.removeMin();vEntry=results[v];if(vEntry.distance===Number.POSITIVE_INFINITY){break}edgeFn(v).forEach(updateNeighbors)}return results}},{"../data/priority-queue":45,"../lodash":49}],36:[function(require,module,exports){var _=require("../lodash");var tarjan=require("./tarjan");module.exports=findCycles;function findCycles(g){return _.filter(tarjan(g),function(cmpt){return cmpt.length>1||cmpt.length===1&&g.hasEdge(cmpt[0],cmpt[0])})}},{"../lodash":49,"./tarjan":43}],37:[function(require,module,exports){var _=require("../lodash");module.exports=floydWarshall;var DEFAULT_WEIGHT_FUNC=_.constant(1);function floydWarshall(g,weightFn,edgeFn){return runFloydWarshall(g,weightFn||DEFAULT_WEIGHT_FUNC,edgeFn||function(v){return g.outEdges(v)})}function runFloydWarshall(g,weightFn,edgeFn){var results={};var nodes=g.nodes();nodes.forEach(function(v){results[v]={};results[v][v]={distance:0};nodes.forEach(function(w){if(v!==w){results[v][w]={distance:Number.POSITIVE_INFINITY}}});edgeFn(v).forEach(function(edge){var w=edge.v===v?edge.w:edge.v;var d=weightFn(edge);results[v][w]={distance:d,predecessor:v}})});nodes.forEach(function(k){var rowK=results[k];nodes.forEach(function(i){var rowI=results[i];nodes.forEach(function(j){var ik=rowI[k];var kj=rowK[j];var ij=rowI[j];var altDistance=ik.distance+kj.distance;if(altDistance0){v=pq.removeMin();if(_.has(parents,v)){result.setEdge(v,parents[v])}else if(init){throw new Error("Input graph is not connected: "+g)}else{init=true}g.nodeEdges(v).forEach(updateNeighbors)}return result}},{"../data/priority-queue":45,"../graph":46,"../lodash":49}],43:[function(require,module,exports){var _=require("../lodash");module.exports=tarjan;function tarjan(g){var index=0;var stack=[];var visited={};// node id -> { onStack, lowlink, index } +var results=[];function dfs(v){var entry=visited[v]={onStack:true,lowlink:index,index:index++};stack.push(v);g.successors(v).forEach(function(w){if(!_.has(visited,w)){dfs(w);entry.lowlink=Math.min(entry.lowlink,visited[w].lowlink)}else if(visited[w].onStack){entry.lowlink=Math.min(entry.lowlink,visited[w].index)}});if(entry.lowlink===entry.index){var cmpt=[];var w;do{w=stack.pop();visited[w].onStack=false;cmpt.push(w)}while(v!==w);results.push(cmpt)}}g.nodes().forEach(function(v){if(!_.has(visited,v)){dfs(v)}});return results}},{"../lodash":49}],44:[function(require,module,exports){var _=require("../lodash");module.exports=topsort;topsort.CycleException=CycleException;function topsort(g){var visited={};var stack={};var results=[];function visit(node){if(_.has(stack,node)){throw new CycleException}if(!_.has(visited,node)){stack[node]=true;visited[node]=true;_.each(g.predecessors(node),visit);delete stack[node];results.push(node)}}_.each(g.sinks(),visit);if(_.size(visited)!==g.nodeCount()){throw new CycleException}return results}function CycleException(){}CycleException.prototype=new Error;// must be an instance of Error to pass testing +},{"../lodash":49}],45:[function(require,module,exports){var _=require("../lodash");module.exports=PriorityQueue; +/** + * A min-priority queue data structure. This algorithm is derived from Cormen, + * et al., "Introduction to Algorithms". The basic idea of a min-priority + * queue is that you can efficiently (in O(1) time) get the smallest key in + * the queue. Adding and removing elements takes O(log n) time. A key can + * have its priority decreased in O(log n) time. + */function PriorityQueue(){this._arr=[];this._keyIndices={}} +/** + * Returns the number of elements in the queue. Takes `O(1)` time. + */PriorityQueue.prototype.size=function(){return this._arr.length}; +/** + * Returns the keys that are in the queue. Takes `O(n)` time. + */PriorityQueue.prototype.keys=function(){return this._arr.map(function(x){return x.key})}; +/** + * Returns `true` if **key** is in the queue and `false` if not. + */PriorityQueue.prototype.has=function(key){return _.has(this._keyIndices,key)}; +/** + * Returns the priority for **key**. If **key** is not present in the queue + * then this function returns `undefined`. Takes `O(1)` time. + * + * @param {Object} key + */PriorityQueue.prototype.priority=function(key){var index=this._keyIndices[key];if(index!==undefined){return this._arr[index].priority}}; +/** + * Returns the key for the minimum element in this queue. If the queue is + * empty this function throws an Error. Takes `O(1)` time. + */PriorityQueue.prototype.min=function(){if(this.size()===0){throw new Error("Queue underflow")}return this._arr[0].key}; +/** + * Inserts a new key into the priority queue. If the key already exists in + * the queue this function returns `false`; otherwise it will return `true`. + * Takes `O(n)` time. + * + * @param {Object} key the key to add + * @param {Number} priority the initial priority for the key + */PriorityQueue.prototype.add=function(key,priority){var keyIndices=this._keyIndices;key=String(key);if(!_.has(keyIndices,key)){var arr=this._arr;var index=arr.length;keyIndices[key]=index;arr.push({key:key,priority:priority});this._decrease(index);return true}return false}; +/** + * Removes and returns the smallest key in the queue. Takes `O(log n)` time. + */PriorityQueue.prototype.removeMin=function(){this._swap(0,this._arr.length-1);var min=this._arr.pop();delete this._keyIndices[min.key];this._heapify(0);return min.key}; +/** + * Decreases the priority for **key** to **priority**. If the new priority is + * greater than the previous priority, this function will throw an Error. + * + * @param {Object} key the key for which to raise priority + * @param {Number} priority the new priority for the key + */PriorityQueue.prototype.decrease=function(key,priority){var index=this._keyIndices[key];if(priority>this._arr[index].priority){throw new Error("New priority is greater than current priority. "+"Key: "+key+" Old: "+this._arr[index].priority+" New: "+priority)}this._arr[index].priority=priority;this._decrease(index)};PriorityQueue.prototype._heapify=function(i){var arr=this._arr;var l=2*i;var r=l+1;var largest=i;if(l>1;if(arr[parent].priority label +this._nodes={};if(this._isCompound){ +// v -> parent +this._parent={}; +// v -> children +this._children={};this._children[GRAPH_NODE]={}} +// v -> edgeObj +this._in={}; +// u -> v -> Number +this._preds={}; +// v -> edgeObj +this._out={}; +// v -> w -> Number +this._sucs={}; +// e -> edgeObj +this._edgeObjs={}; +// e -> label +this._edgeLabels={}} +/* Number of nodes in the graph. Should only be changed by the implementation. */Graph.prototype._nodeCount=0; +/* Number of edges in the graph. Should only be changed by the implementation. */Graph.prototype._edgeCount=0; +/* === Graph functions ========= */Graph.prototype.isDirected=function(){return this._isDirected};Graph.prototype.isMultigraph=function(){return this._isMultigraph};Graph.prototype.isCompound=function(){return this._isCompound};Graph.prototype.setGraph=function(label){this._label=label;return this};Graph.prototype.graph=function(){return this._label}; +/* === Node functions ========== */Graph.prototype.setDefaultNodeLabel=function(newDefault){if(!_.isFunction(newDefault)){newDefault=_.constant(newDefault)}this._defaultNodeLabelFn=newDefault;return this};Graph.prototype.nodeCount=function(){return this._nodeCount};Graph.prototype.nodes=function(){return _.keys(this._nodes)};Graph.prototype.sources=function(){var self=this;return _.filter(this.nodes(),function(v){return _.isEmpty(self._in[v])})};Graph.prototype.sinks=function(){var self=this;return _.filter(this.nodes(),function(v){return _.isEmpty(self._out[v])})};Graph.prototype.setNodes=function(vs,value){var args=arguments;var self=this;_.each(vs,function(v){if(args.length>1){self.setNode(v,value)}else{self.setNode(v)}});return this};Graph.prototype.setNode=function(v,value){if(_.has(this._nodes,v)){if(arguments.length>1){this._nodes[v]=value}return this}this._nodes[v]=arguments.length>1?value:this._defaultNodeLabelFn(v);if(this._isCompound){this._parent[v]=GRAPH_NODE;this._children[v]={};this._children[GRAPH_NODE][v]=true}this._in[v]={};this._preds[v]={};this._out[v]={};this._sucs[v]={};++this._nodeCount;return this};Graph.prototype.node=function(v){return this._nodes[v]};Graph.prototype.hasNode=function(v){return _.has(this._nodes,v)};Graph.prototype.removeNode=function(v){var self=this;if(_.has(this._nodes,v)){var removeEdge=function(e){self.removeEdge(self._edgeObjs[e])};delete this._nodes[v];if(this._isCompound){this._removeFromParentsChildList(v);delete this._parent[v];_.each(this.children(v),function(child){self.setParent(child)});delete this._children[v]}_.each(_.keys(this._in[v]),removeEdge);delete this._in[v];delete this._preds[v];_.each(_.keys(this._out[v]),removeEdge);delete this._out[v];delete this._sucs[v];--this._nodeCount}return this};Graph.prototype.setParent=function(v,parent){if(!this._isCompound){throw new Error("Cannot set parent in a non-compound graph")}if(_.isUndefined(parent)){parent=GRAPH_NODE}else{ +// Coerce parent to string +parent+="";for(var ancestor=parent;!_.isUndefined(ancestor);ancestor=this.parent(ancestor)){if(ancestor===v){throw new Error("Setting "+parent+" as parent of "+v+" would create a cycle")}}this.setNode(parent)}this.setNode(v);this._removeFromParentsChildList(v);this._parent[v]=parent;this._children[parent][v]=true;return this};Graph.prototype._removeFromParentsChildList=function(v){delete this._children[this._parent[v]][v]};Graph.prototype.parent=function(v){if(this._isCompound){var parent=this._parent[v];if(parent!==GRAPH_NODE){return parent}}};Graph.prototype.children=function(v){if(_.isUndefined(v)){v=GRAPH_NODE}if(this._isCompound){var children=this._children[v];if(children){return _.keys(children)}}else if(v===GRAPH_NODE){return this.nodes()}else if(this.hasNode(v)){return[]}};Graph.prototype.predecessors=function(v){var predsV=this._preds[v];if(predsV){return _.keys(predsV)}};Graph.prototype.successors=function(v){var sucsV=this._sucs[v];if(sucsV){return _.keys(sucsV)}};Graph.prototype.neighbors=function(v){var preds=this.predecessors(v);if(preds){return _.union(preds,this.successors(v))}};Graph.prototype.isLeaf=function(v){var neighbors;if(this.isDirected()){neighbors=this.successors(v)}else{neighbors=this.neighbors(v)}return neighbors.length===0};Graph.prototype.filterNodes=function(filter){var copy=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});copy.setGraph(this.graph());var self=this;_.each(this._nodes,function(value,v){if(filter(v)){copy.setNode(v,value)}});_.each(this._edgeObjs,function(e){if(copy.hasNode(e.v)&©.hasNode(e.w)){copy.setEdge(e,self.edge(e))}});var parents={};function findParent(v){var parent=self.parent(v);if(parent===undefined||copy.hasNode(parent)){parents[v]=parent;return parent}else if(parent in parents){return parents[parent]}else{return findParent(parent)}}if(this._isCompound){_.each(copy.nodes(),function(v){copy.setParent(v,findParent(v))})}return copy}; +/* === Edge functions ========== */Graph.prototype.setDefaultEdgeLabel=function(newDefault){if(!_.isFunction(newDefault)){newDefault=_.constant(newDefault)}this._defaultEdgeLabelFn=newDefault;return this};Graph.prototype.edgeCount=function(){return this._edgeCount};Graph.prototype.edges=function(){return _.values(this._edgeObjs)};Graph.prototype.setPath=function(vs,value){var self=this;var args=arguments;_.reduce(vs,function(v,w){if(args.length>1){self.setEdge(v,w,value)}else{self.setEdge(v,w)}return w});return this}; +/* + * setEdge(v, w, [value, [name]]) + * setEdge({ v, w, [name] }, [value]) + */Graph.prototype.setEdge=function(){var v,w,name,value;var valueSpecified=false;var arg0=arguments[0];if(typeof arg0==="object"&&arg0!==null&&"v"in arg0){v=arg0.v;w=arg0.w;name=arg0.name;if(arguments.length===2){value=arguments[1];valueSpecified=true}}else{v=arg0;w=arguments[1];name=arguments[3];if(arguments.length>2){value=arguments[2];valueSpecified=true}}v=""+v;w=""+w;if(!_.isUndefined(name)){name=""+name}var e=edgeArgsToId(this._isDirected,v,w,name);if(_.has(this._edgeLabels,e)){if(valueSpecified){this._edgeLabels[e]=value}return this}if(!_.isUndefined(name)&&!this._isMultigraph){throw new Error("Cannot set a named edge when isMultigraph = false")} +// It didn't exist, so we need to create it. +// First ensure the nodes exist. +this.setNode(v);this.setNode(w);this._edgeLabels[e]=valueSpecified?value:this._defaultEdgeLabelFn(v,w,name);var edgeObj=edgeArgsToObj(this._isDirected,v,w,name); +// Ensure we add undirected edges in a consistent way. +v=edgeObj.v;w=edgeObj.w;Object.freeze(edgeObj);this._edgeObjs[e]=edgeObj;incrementOrInitEntry(this._preds[w],v);incrementOrInitEntry(this._sucs[v],w);this._in[w][e]=edgeObj;this._out[v][e]=edgeObj;this._edgeCount++;return this};Graph.prototype.edge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);return this._edgeLabels[e]};Graph.prototype.hasEdge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);return _.has(this._edgeLabels,e)};Graph.prototype.removeEdge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);var edge=this._edgeObjs[e];if(edge){v=edge.v;w=edge.w;delete this._edgeLabels[e];delete this._edgeObjs[e];decrementOrRemoveEntry(this._preds[w],v);decrementOrRemoveEntry(this._sucs[v],w);delete this._in[w][e];delete this._out[v][e];this._edgeCount--}return this};Graph.prototype.inEdges=function(v,u){var inV=this._in[v];if(inV){var edges=_.values(inV);if(!u){return edges}return _.filter(edges,function(edge){return edge.v===u})}};Graph.prototype.outEdges=function(v,w){var outV=this._out[v];if(outV){var edges=_.values(outV);if(!w){return edges}return _.filter(edges,function(edge){return edge.w===w})}};Graph.prototype.nodeEdges=function(v,w){var inEdges=this.inEdges(v,w);if(inEdges){return inEdges.concat(this.outEdges(v,w))}};function incrementOrInitEntry(map,k){if(map[k]){map[k]++}else{map[k]=1}}function decrementOrRemoveEntry(map,k){if(!--map[k]){delete map[k]}}function edgeArgsToId(isDirected,v_,w_,name){var v=""+v_;var w=""+w_;if(!isDirected&&v>w){var tmp=v;v=w;w=tmp}return v+EDGE_KEY_DELIM+w+EDGE_KEY_DELIM+(_.isUndefined(name)?DEFAULT_EDGE_NAME:name)}function edgeArgsToObj(isDirected,v_,w_,name){var v=""+v_;var w=""+w_;if(!isDirected&&v>w){var tmp=v;v=w;w=tmp}var edgeObj={v:v,w:w};if(name){edgeObj.name=name}return edgeObj}function edgeObjToId(isDirected,edgeObj){return edgeArgsToId(isDirected,edgeObj.v,edgeObj.w,edgeObj.name)}},{"./lodash":49}],47:[function(require,module,exports){ +// Includes only the "core" of graphlib +module.exports={Graph:require("./graph"),version:require("./version")}},{"./graph":46,"./version":50}],48:[function(require,module,exports){var _=require("./lodash");var Graph=require("./graph");module.exports={write:write,read:read};function write(g){var json={options:{directed:g.isDirected(),multigraph:g.isMultigraph(),compound:g.isCompound()},nodes:writeNodes(g),edges:writeEdges(g)};if(!_.isUndefined(g.graph())){json.value=_.clone(g.graph())}return json}function writeNodes(g){return _.map(g.nodes(),function(v){var nodeValue=g.node(v);var parent=g.parent(v);var node={v:v};if(!_.isUndefined(nodeValue)){node.value=nodeValue}if(!_.isUndefined(parent)){node.parent=parent}return node})}function writeEdges(g){return _.map(g.edges(),function(e){var edgeValue=g.edge(e);var edge={v:e.v,w:e.w};if(!_.isUndefined(e.name)){edge.name=e.name}if(!_.isUndefined(edgeValue)){edge.value=edgeValue}return edge})}function read(json){var g=new Graph(json.options).setGraph(json.value);_.each(json.nodes,function(entry){g.setNode(entry.v,entry.value);if(entry.parent){g.setParent(entry.v,entry.parent)}});_.each(json.edges,function(entry){g.setEdge({v:entry.v,w:entry.w,name:entry.name},entry.value)});return g}},{"./graph":46,"./lodash":49}],49:[function(require,module,exports){ +/* global window */ +var lodash;if(typeof require==="function"){try{lodash={clone:require("lodash/clone"),constant:require("lodash/constant"),each:require("lodash/each"),filter:require("lodash/filter"),has:require("lodash/has"),isArray:require("lodash/isArray"),isEmpty:require("lodash/isEmpty"),isFunction:require("lodash/isFunction"),isUndefined:require("lodash/isUndefined"),keys:require("lodash/keys"),map:require("lodash/map"),reduce:require("lodash/reduce"),size:require("lodash/size"),transform:require("lodash/transform"),union:require("lodash/union"),values:require("lodash/values")}}catch(e){ +// continue regardless of error +}}if(!lodash){lodash=window._}module.exports=lodash},{"lodash/clone":226,"lodash/constant":228,"lodash/each":230,"lodash/filter":232,"lodash/has":239,"lodash/isArray":243,"lodash/isEmpty":247,"lodash/isFunction":248,"lodash/isUndefined":258,"lodash/keys":259,"lodash/map":262,"lodash/reduce":274,"lodash/size":275,"lodash/transform":284,"lodash/union":285,"lodash/values":287}],50:[function(require,module,exports){module.exports="2.1.8"},{}],51:[function(require,module,exports){var getNative=require("./_getNative"),root=require("./_root"); +/* Built-in method references that are verified to be native. */var DataView=getNative(root,"DataView");module.exports=DataView},{"./_getNative":163,"./_root":208}],52:[function(require,module,exports){var hashClear=require("./_hashClear"),hashDelete=require("./_hashDelete"),hashGet=require("./_hashGet"),hashHas=require("./_hashHas"),hashSet=require("./_hashSet"); +/** + * Creates a hash object. + * + * @private + * @constructor + * @param {Array} [entries] The key-value pairs to cache. + */function Hash(entries){var index=-1,length=entries==null?0:entries.length;this.clear();while(++index-1}module.exports=arrayIncludes},{"./_baseIndexOf":95}],67:[function(require,module,exports){ +/** + * This function is like `arrayIncludes` except that it accepts a comparator. + * + * @private + * @param {Array} [array] The array to inspect. + * @param {*} target The value to search for. + * @param {Function} comparator The comparator invoked per element. + * @returns {boolean} Returns `true` if `target` is found, else `false`. + */ +function arrayIncludesWith(array,value,comparator){var index=-1,length=array==null?0:array.length;while(++index0&&predicate(value)){if(depth>1){ +// Recursively flatten arrays (susceptible to call stack limits). +baseFlatten(value,depth-1,predicate,isStrict,result)}else{arrayPush(result,value)}}else if(!isStrict){result[result.length]=value}}return result}module.exports=baseFlatten},{"./_arrayPush":70,"./_isFlattenable":180}],87:[function(require,module,exports){var createBaseFor=require("./_createBaseFor"); +/** + * The base implementation of `baseForOwn` which iterates over `object` + * properties returned by `keysFunc` and invokes `iteratee` for each property. + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * @private + * @param {Object} object The object to iterate over. + * @param {Function} iteratee The function invoked per iteration. + * @param {Function} keysFunc The function to get the keys of `object`. + * @returns {Object} Returns `object`. + */var baseFor=createBaseFor();module.exports=baseFor},{"./_createBaseFor":149}],88:[function(require,module,exports){var baseFor=require("./_baseFor"),keys=require("./keys"); +/** + * The base implementation of `_.forOwn` without support for iteratee shorthands. + * + * @private + * @param {Object} object The object to iterate over. + * @param {Function} iteratee The function invoked per iteration. + * @returns {Object} Returns `object`. + */function baseForOwn(object,iteratee){return object&&baseFor(object,iteratee,keys)}module.exports=baseForOwn},{"./_baseFor":87,"./keys":259}],89:[function(require,module,exports){var castPath=require("./_castPath"),toKey=require("./_toKey"); +/** + * The base implementation of `_.get` without support for default values. + * + * @private + * @param {Object} object The object to query. + * @param {Array|string} path The path of the property to get. + * @returns {*} Returns the resolved value. + */function baseGet(object,path){path=castPath(path,object);var index=0,length=path.length;while(object!=null&&indexother}module.exports=baseGt},{}],93:[function(require,module,exports){ +/** Used for built-in method references. */ +var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** + * The base implementation of `_.has` without support for deep paths. + * + * @private + * @param {Object} [object] The object to query. + * @param {Array|string} key The key to check. + * @returns {boolean} Returns `true` if `key` exists, else `false`. + */function baseHas(object,key){return object!=null&&hasOwnProperty.call(object,key)}module.exports=baseHas},{}],94:[function(require,module,exports){ +/** + * The base implementation of `_.hasIn` without support for deep paths. + * + * @private + * @param {Object} [object] The object to query. + * @param {Array|string} key The key to check. + * @returns {boolean} Returns `true` if `key` exists, else `false`. + */ +function baseHasIn(object,key){return object!=null&&key in Object(object)}module.exports=baseHasIn},{}],95:[function(require,module,exports){var baseFindIndex=require("./_baseFindIndex"),baseIsNaN=require("./_baseIsNaN"),strictIndexOf=require("./_strictIndexOf"); +/** + * The base implementation of `_.indexOf` without `fromIndex` bounds checks. + * + * @private + * @param {Array} array The array to inspect. + * @param {*} value The value to search for. + * @param {number} fromIndex The index to search from. + * @returns {number} Returns the index of the matched value, else `-1`. + */function baseIndexOf(array,value,fromIndex){return value===value?strictIndexOf(array,value,fromIndex):baseFindIndex(array,baseIsNaN,fromIndex)}module.exports=baseIndexOf},{"./_baseFindIndex":85,"./_baseIsNaN":101,"./_strictIndexOf":220}],96:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var argsTag="[object Arguments]"; +/** + * The base implementation of `_.isArguments`. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an `arguments` object, + */function baseIsArguments(value){return isObjectLike(value)&&baseGetTag(value)==argsTag}module.exports=baseIsArguments},{"./_baseGetTag":91,"./isObjectLike":252}],97:[function(require,module,exports){var baseIsEqualDeep=require("./_baseIsEqualDeep"),isObjectLike=require("./isObjectLike"); +/** + * The base implementation of `_.isEqual` which supports partial comparisons + * and tracks traversed objects. + * + * @private + * @param {*} value The value to compare. + * @param {*} other The other value to compare. + * @param {boolean} bitmask The bitmask flags. + * 1 - Unordered comparison + * 2 - Partial comparison + * @param {Function} [customizer] The function to customize comparisons. + * @param {Object} [stack] Tracks traversed `value` and `other` objects. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + */function baseIsEqual(value,other,bitmask,customizer,stack){if(value===other){return true}if(value==null||other==null||!isObjectLike(value)&&!isObjectLike(other)){return value!==value&&other!==other}return baseIsEqualDeep(value,other,bitmask,customizer,baseIsEqual,stack)}module.exports=baseIsEqual},{"./_baseIsEqualDeep":98,"./isObjectLike":252}],98:[function(require,module,exports){var Stack=require("./_Stack"),equalArrays=require("./_equalArrays"),equalByTag=require("./_equalByTag"),equalObjects=require("./_equalObjects"),getTag=require("./_getTag"),isArray=require("./isArray"),isBuffer=require("./isBuffer"),isTypedArray=require("./isTypedArray"); +/** Used to compose bitmasks for value comparisons. */var COMPARE_PARTIAL_FLAG=1; +/** `Object#toString` result references. */var argsTag="[object Arguments]",arrayTag="[object Array]",objectTag="[object Object]"; +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** + * A specialized version of `baseIsEqual` for arrays and objects which performs + * deep comparisons and tracks traversed objects enabling objects with circular + * references to be compared. + * + * @private + * @param {Object} object The object to compare. + * @param {Object} other The other object to compare. + * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. + * @param {Function} customizer The function to customize comparisons. + * @param {Function} equalFunc The function to determine equivalents of values. + * @param {Object} [stack] Tracks traversed `object` and `other` objects. + * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. + */function baseIsEqualDeep(object,other,bitmask,customizer,equalFunc,stack){var objIsArr=isArray(object),othIsArr=isArray(other),objTag=objIsArr?arrayTag:getTag(object),othTag=othIsArr?arrayTag:getTag(other);objTag=objTag==argsTag?objectTag:objTag;othTag=othTag==argsTag?objectTag:othTag;var objIsObj=objTag==objectTag,othIsObj=othTag==objectTag,isSameTag=objTag==othTag;if(isSameTag&&isBuffer(object)){if(!isBuffer(other)){return false}objIsArr=true;objIsObj=false}if(isSameTag&&!objIsObj){stack||(stack=new Stack);return objIsArr||isTypedArray(object)?equalArrays(object,other,bitmask,customizer,equalFunc,stack):equalByTag(object,other,objTag,bitmask,customizer,equalFunc,stack)}if(!(bitmask&COMPARE_PARTIAL_FLAG)){var objIsWrapped=objIsObj&&hasOwnProperty.call(object,"__wrapped__"),othIsWrapped=othIsObj&&hasOwnProperty.call(other,"__wrapped__");if(objIsWrapped||othIsWrapped){var objUnwrapped=objIsWrapped?object.value():object,othUnwrapped=othIsWrapped?other.value():other;stack||(stack=new Stack);return equalFunc(objUnwrapped,othUnwrapped,bitmask,customizer,stack)}}if(!isSameTag){return false}stack||(stack=new Stack);return equalObjects(object,other,bitmask,customizer,equalFunc,stack)}module.exports=baseIsEqualDeep},{"./_Stack":59,"./_equalArrays":154,"./_equalByTag":155,"./_equalObjects":156,"./_getTag":168,"./isArray":243,"./isBuffer":246,"./isTypedArray":257}],99:[function(require,module,exports){var getTag=require("./_getTag"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var mapTag="[object Map]"; +/** + * The base implementation of `_.isMap` without Node.js optimizations. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a map, else `false`. + */function baseIsMap(value){return isObjectLike(value)&&getTag(value)==mapTag}module.exports=baseIsMap},{"./_getTag":168,"./isObjectLike":252}],100:[function(require,module,exports){var Stack=require("./_Stack"),baseIsEqual=require("./_baseIsEqual"); +/** Used to compose bitmasks for value comparisons. */var COMPARE_PARTIAL_FLAG=1,COMPARE_UNORDERED_FLAG=2; +/** + * The base implementation of `_.isMatch` without support for iteratee shorthands. + * + * @private + * @param {Object} object The object to inspect. + * @param {Object} source The object of property values to match. + * @param {Array} matchData The property names, values, and compare flags to match. + * @param {Function} [customizer] The function to customize comparisons. + * @returns {boolean} Returns `true` if `object` is a match, else `false`. + */function baseIsMatch(object,source,matchData,customizer){var index=matchData.length,length=index,noCustomizer=!customizer;if(object==null){return!length}object=Object(object);while(index--){var data=matchData[index];if(noCustomizer&&data[2]?data[1]!==object[data[0]]:!(data[0]in object)){return false}}while(++index=LARGE_ARRAY_SIZE){var set=iteratee?null:createSet(array);if(set){return setToArray(set)}isCommon=false;includes=cacheHas;seen=new SetCache}else{seen=iteratee?[]:result}outer:while(++indexother||valIsSymbol&&othIsDefined&&othIsReflexive&&!othIsNull&&!othIsSymbol||valIsNull&&othIsDefined&&othIsReflexive||!valIsDefined&&othIsReflexive||!valIsReflexive){return 1}if(!valIsNull&&!valIsSymbol&&!othIsSymbol&&value=ordersLength){return result}var order=orders[index];return result*(order=="desc"?-1:1)}} +// Fixes an `Array#sort` bug in the JS engine embedded in Adobe applications +// that causes it, under certain circumstances, to provide the same value for +// `object` and `other`. See https://github.com/jashkenas/underscore/pull/1247 +// for more details. +// +// This also ensures a stable sort in V8 and other engines. +// See https://bugs.chromium.org/p/v8/issues/detail?id=90 for more details. +return object.index-other.index}module.exports=compareMultiple},{"./_compareAscending":140}],142:[function(require,module,exports){ +/** + * Copies the values of `source` to `array`. + * + * @private + * @param {Array} source The array to copy values from. + * @param {Array} [array=[]] The array to copy values to. + * @returns {Array} Returns `array`. + */ +function copyArray(source,array){var index=-1,length=source.length;array||(array=Array(length));while(++index1?sources[length-1]:undefined,guard=length>2?sources[2]:undefined;customizer=assigner.length>3&&typeof customizer=="function"?(length--,customizer):undefined;if(guard&&isIterateeCall(sources[0],sources[1],guard)){customizer=length<3?undefined:customizer;length=1}object=Object(object);while(++index-1?iterable[iteratee?collection[index]:index]:undefined}}module.exports=createFind},{"./_baseIteratee":105,"./isArrayLike":244,"./keys":259}],151:[function(require,module,exports){var baseRange=require("./_baseRange"),isIterateeCall=require("./_isIterateeCall"),toFinite=require("./toFinite"); +/** + * Creates a `_.range` or `_.rangeRight` function. + * + * @private + * @param {boolean} [fromRight] Specify iterating from right to left. + * @returns {Function} Returns the new range function. + */function createRange(fromRight){return function(start,end,step){if(step&&typeof step!="number"&&isIterateeCall(start,end,step)){end=step=undefined} +// Ensure the sign of `-0` is preserved. +start=toFinite(start);if(end===undefined){end=start;start=0}else{end=toFinite(end)}step=step===undefined?startarrLength)){return false} +// Assume cyclic values are equal. +var stacked=stack.get(array);if(stacked&&stack.get(other)){return stacked==other}var index=-1,result=true,seen=bitmask&COMPARE_UNORDERED_FLAG?new SetCache:undefined;stack.set(array,other);stack.set(other,array); +// Ignore non-index properties. +while(++index-1&&value%1==0&&value-1}module.exports=listCacheHas},{"./_assocIndexOf":76}],192:[function(require,module,exports){var assocIndexOf=require("./_assocIndexOf"); +/** + * Sets the list cache `key` to `value`. + * + * @private + * @name set + * @memberOf ListCache + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the list cache instance. + */function listCacheSet(key,value){var data=this.__data__,index=assocIndexOf(data,key);if(index<0){++this.size;data.push([key,value])}else{data[index][1]=value}return this}module.exports=listCacheSet},{"./_assocIndexOf":76}],193:[function(require,module,exports){var Hash=require("./_Hash"),ListCache=require("./_ListCache"),Map=require("./_Map"); +/** + * Removes all key-value entries from the map. + * + * @private + * @name clear + * @memberOf MapCache + */function mapCacheClear(){this.size=0;this.__data__={hash:new Hash,map:new(Map||ListCache),string:new Hash}}module.exports=mapCacheClear},{"./_Hash":52,"./_ListCache":53,"./_Map":54}],194:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Removes `key` and its value from the map. + * + * @private + * @name delete + * @memberOf MapCache + * @param {string} key The key of the value to remove. + * @returns {boolean} Returns `true` if the entry was removed, else `false`. + */function mapCacheDelete(key){var result=getMapData(this,key)["delete"](key);this.size-=result?1:0;return result}module.exports=mapCacheDelete},{"./_getMapData":161}],195:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Gets the map value for `key`. + * + * @private + * @name get + * @memberOf MapCache + * @param {string} key The key of the value to get. + * @returns {*} Returns the entry value. + */function mapCacheGet(key){return getMapData(this,key).get(key)}module.exports=mapCacheGet},{"./_getMapData":161}],196:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Checks if a map value for `key` exists. + * + * @private + * @name has + * @memberOf MapCache + * @param {string} key The key of the entry to check. + * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. + */function mapCacheHas(key){return getMapData(this,key).has(key)}module.exports=mapCacheHas},{"./_getMapData":161}],197:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Sets the map `key` to `value`. + * + * @private + * @name set + * @memberOf MapCache + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the map cache instance. + */function mapCacheSet(key,value){var data=getMapData(this,key),size=data.size;data.set(key,value);this.size+=data.size==size?0:1;return this}module.exports=mapCacheSet},{"./_getMapData":161}],198:[function(require,module,exports){ +/** + * Converts `map` to its key-value pairs. + * + * @private + * @param {Object} map The map to convert. + * @returns {Array} Returns the key-value pairs. + */ +function mapToArray(map){var index=-1,result=Array(map.size);map.forEach(function(value,key){result[++index]=[key,value]});return result}module.exports=mapToArray},{}],199:[function(require,module,exports){ +/** + * A specialized version of `matchesProperty` for source values suitable + * for strict equality comparisons, i.e. `===`. + * + * @private + * @param {string} key The key of the property to get. + * @param {*} srcValue The value to match. + * @returns {Function} Returns the new spec function. + */ +function matchesStrictComparable(key,srcValue){return function(object){if(object==null){return false}return object[key]===srcValue&&(srcValue!==undefined||key in Object(object))}}module.exports=matchesStrictComparable},{}],200:[function(require,module,exports){var memoize=require("./memoize"); +/** Used as the maximum memoize cache size. */var MAX_MEMOIZE_SIZE=500; +/** + * A specialized version of `_.memoize` which clears the memoized function's + * cache when it exceeds `MAX_MEMOIZE_SIZE`. + * + * @private + * @param {Function} func The function to have its output memoized. + * @returns {Function} Returns the new memoized function. + */function memoizeCapped(func){var result=memoize(func,function(key){if(cache.size===MAX_MEMOIZE_SIZE){cache.clear()}return key});var cache=result.cache;return result}module.exports=memoizeCapped},{"./memoize":265}],201:[function(require,module,exports){var getNative=require("./_getNative"); +/* Built-in method references that are verified to be native. */var nativeCreate=getNative(Object,"create");module.exports=nativeCreate},{"./_getNative":163}],202:[function(require,module,exports){var overArg=require("./_overArg"); +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeKeys=overArg(Object.keys,Object);module.exports=nativeKeys},{"./_overArg":206}],203:[function(require,module,exports){ +/** + * This function is like + * [`Object.keys`](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) + * except that it includes inherited enumerable properties. + * + * @private + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + */ +function nativeKeysIn(object){var result=[];if(object!=null){for(var key in Object(object)){result.push(key)}}return result}module.exports=nativeKeysIn},{}],204:[function(require,module,exports){var freeGlobal=require("./_freeGlobal"); +/** Detect free variable `exports`. */var freeExports=typeof exports=="object"&&exports&&!exports.nodeType&&exports; +/** Detect free variable `module`. */var freeModule=freeExports&&typeof module=="object"&&module&&!module.nodeType&&module; +/** Detect the popular CommonJS extension `module.exports`. */var moduleExports=freeModule&&freeModule.exports===freeExports; +/** Detect free variable `process` from Node.js. */var freeProcess=moduleExports&&freeGlobal.process; +/** Used to access faster Node.js helpers. */var nodeUtil=function(){try{ +// Use `util.types` for Node.js 10+. +var types=freeModule&&freeModule.require&&freeModule.require("util").types;if(types){return types} +// Legacy `process.binding('util')` for Node.js < 10. +return freeProcess&&freeProcess.binding&&freeProcess.binding("util")}catch(e){}}();module.exports=nodeUtil},{"./_freeGlobal":158}],205:[function(require,module,exports){ +/** Used for built-in method references. */ +var objectProto=Object.prototype; +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */var nativeObjectToString=objectProto.toString; +/** + * Converts `value` to a string using `Object.prototype.toString`. + * + * @private + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + */function objectToString(value){return nativeObjectToString.call(value)}module.exports=objectToString},{}],206:[function(require,module,exports){ +/** + * Creates a unary function that invokes `func` with its argument transformed. + * + * @private + * @param {Function} func The function to wrap. + * @param {Function} transform The argument transform. + * @returns {Function} Returns the new function. + */ +function overArg(func,transform){return function(arg){return func(transform(arg))}}module.exports=overArg},{}],207:[function(require,module,exports){var apply=require("./_apply"); +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeMax=Math.max; +/** + * A specialized version of `baseRest` which transforms the rest array. + * + * @private + * @param {Function} func The function to apply a rest parameter to. + * @param {number} [start=func.length-1] The start position of the rest parameter. + * @param {Function} transform The rest array transform. + * @returns {Function} Returns the new function. + */function overRest(func,start,transform){start=nativeMax(start===undefined?func.length-1:start,0);return function(){var args=arguments,index=-1,length=nativeMax(args.length-start,0),array=Array(length);while(++index0){if(++count>=HOT_COUNT){return arguments[0]}}else{count=0}return func.apply(undefined,arguments)}}module.exports=shortOut},{}],215:[function(require,module,exports){var ListCache=require("./_ListCache"); +/** + * Removes all key-value entries from the stack. + * + * @private + * @name clear + * @memberOf Stack + */function stackClear(){this.__data__=new ListCache;this.size=0}module.exports=stackClear},{"./_ListCache":53}],216:[function(require,module,exports){ +/** + * Removes `key` and its value from the stack. + * + * @private + * @name delete + * @memberOf Stack + * @param {string} key The key of the value to remove. + * @returns {boolean} Returns `true` if the entry was removed, else `false`. + */ +function stackDelete(key){var data=this.__data__,result=data["delete"](key);this.size=data.size;return result}module.exports=stackDelete},{}],217:[function(require,module,exports){ +/** + * Gets the stack value for `key`. + * + * @private + * @name get + * @memberOf Stack + * @param {string} key The key of the value to get. + * @returns {*} Returns the entry value. + */ +function stackGet(key){return this.__data__.get(key)}module.exports=stackGet},{}],218:[function(require,module,exports){ +/** + * Checks if a stack value for `key` exists. + * + * @private + * @name has + * @memberOf Stack + * @param {string} key The key of the entry to check. + * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. + */ +function stackHas(key){return this.__data__.has(key)}module.exports=stackHas},{}],219:[function(require,module,exports){var ListCache=require("./_ListCache"),Map=require("./_Map"),MapCache=require("./_MapCache"); +/** Used as the size to enable large array optimizations. */var LARGE_ARRAY_SIZE=200; +/** + * Sets the stack `key` to `value`. + * + * @private + * @name set + * @memberOf Stack + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the stack cache instance. + */function stackSet(key,value){var data=this.__data__;if(data instanceof ListCache){var pairs=data.__data__;if(!Map||pairs.length true + */function clone(value){return baseClone(value,CLONE_SYMBOLS_FLAG)}module.exports=clone},{"./_baseClone":80}],227:[function(require,module,exports){var baseClone=require("./_baseClone"); +/** Used to compose bitmasks for cloning. */var CLONE_DEEP_FLAG=1,CLONE_SYMBOLS_FLAG=4; +/** + * This method is like `_.clone` except that it recursively clones `value`. + * + * @static + * @memberOf _ + * @since 1.0.0 + * @category Lang + * @param {*} value The value to recursively clone. + * @returns {*} Returns the deep cloned value. + * @see _.clone + * @example + * + * var objects = [{ 'a': 1 }, { 'b': 2 }]; + * + * var deep = _.cloneDeep(objects); + * console.log(deep[0] === objects[0]); + * // => false + */function cloneDeep(value){return baseClone(value,CLONE_DEEP_FLAG|CLONE_SYMBOLS_FLAG)}module.exports=cloneDeep},{"./_baseClone":80}],228:[function(require,module,exports){ +/** + * Creates a function that returns `value`. + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Util + * @param {*} value The value to return from the new function. + * @returns {Function} Returns the new constant function. + * @example + * + * var objects = _.times(2, _.constant({ 'a': 1 })); + * + * console.log(objects); + * // => [{ 'a': 1 }, { 'a': 1 }] + * + * console.log(objects[0] === objects[1]); + * // => true + */ +function constant(value){return function(){return value}}module.exports=constant},{}],229:[function(require,module,exports){var baseRest=require("./_baseRest"),eq=require("./eq"),isIterateeCall=require("./_isIterateeCall"),keysIn=require("./keysIn"); +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** + * Assigns own and inherited enumerable string keyed properties of source + * objects to the destination object for all destination properties that + * resolve to `undefined`. Source objects are applied from left to right. + * Once a property is set, additional values of the same property are ignored. + * + * **Note:** This method mutates `object`. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The destination object. + * @param {...Object} [sources] The source objects. + * @returns {Object} Returns `object`. + * @see _.defaultsDeep + * @example + * + * _.defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 }); + * // => { 'a': 1, 'b': 2 } + */var defaults=baseRest(function(object,sources){object=Object(object);var index=-1;var length=sources.length;var guard=length>2?sources[2]:undefined;if(guard&&isIterateeCall(sources[0],sources[1],guard)){length=1}while(++index true + * + * _.eq(object, other); + * // => false + * + * _.eq('a', 'a'); + * // => true + * + * _.eq('a', Object('a')); + * // => false + * + * _.eq(NaN, NaN); + * // => true + */ +function eq(value,other){return value===other||value!==value&&other!==other}module.exports=eq},{}],232:[function(require,module,exports){var arrayFilter=require("./_arrayFilter"),baseFilter=require("./_baseFilter"),baseIteratee=require("./_baseIteratee"),isArray=require("./isArray"); +/** + * Iterates over elements of `collection`, returning an array of all elements + * `predicate` returns truthy for. The predicate is invoked with three + * arguments: (value, index|key, collection). + * + * **Note:** Unlike `_.remove`, this method returns a new array. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [predicate=_.identity] The function invoked per iteration. + * @returns {Array} Returns the new filtered array. + * @see _.reject + * @example + * + * var users = [ + * { 'user': 'barney', 'age': 36, 'active': true }, + * { 'user': 'fred', 'age': 40, 'active': false } + * ]; + * + * _.filter(users, function(o) { return !o.active; }); + * // => objects for ['fred'] + * + * // The `_.matches` iteratee shorthand. + * _.filter(users, { 'age': 36, 'active': true }); + * // => objects for ['barney'] + * + * // The `_.matchesProperty` iteratee shorthand. + * _.filter(users, ['active', false]); + * // => objects for ['fred'] + * + * // The `_.property` iteratee shorthand. + * _.filter(users, 'active'); + * // => objects for ['barney'] + */function filter(collection,predicate){var func=isArray(collection)?arrayFilter:baseFilter;return func(collection,baseIteratee(predicate,3))}module.exports=filter},{"./_arrayFilter":65,"./_baseFilter":84,"./_baseIteratee":105,"./isArray":243}],233:[function(require,module,exports){var createFind=require("./_createFind"),findIndex=require("./findIndex"); +/** + * Iterates over elements of `collection`, returning the first element + * `predicate` returns truthy for. The predicate is invoked with three + * arguments: (value, index|key, collection). + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to inspect. + * @param {Function} [predicate=_.identity] The function invoked per iteration. + * @param {number} [fromIndex=0] The index to search from. + * @returns {*} Returns the matched element, else `undefined`. + * @example + * + * var users = [ + * { 'user': 'barney', 'age': 36, 'active': true }, + * { 'user': 'fred', 'age': 40, 'active': false }, + * { 'user': 'pebbles', 'age': 1, 'active': true } + * ]; + * + * _.find(users, function(o) { return o.age < 40; }); + * // => object for 'barney' + * + * // The `_.matches` iteratee shorthand. + * _.find(users, { 'age': 1, 'active': true }); + * // => object for 'pebbles' + * + * // The `_.matchesProperty` iteratee shorthand. + * _.find(users, ['active', false]); + * // => object for 'fred' + * + * // The `_.property` iteratee shorthand. + * _.find(users, 'active'); + * // => object for 'barney' + */var find=createFind(findIndex);module.exports=find},{"./_createFind":150,"./findIndex":234}],234:[function(require,module,exports){var baseFindIndex=require("./_baseFindIndex"),baseIteratee=require("./_baseIteratee"),toInteger=require("./toInteger"); +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeMax=Math.max; +/** + * This method is like `_.find` except that it returns the index of the first + * element `predicate` returns truthy for instead of the element itself. + * + * @static + * @memberOf _ + * @since 1.1.0 + * @category Array + * @param {Array} array The array to inspect. + * @param {Function} [predicate=_.identity] The function invoked per iteration. + * @param {number} [fromIndex=0] The index to search from. + * @returns {number} Returns the index of the found element, else `-1`. + * @example + * + * var users = [ + * { 'user': 'barney', 'active': false }, + * { 'user': 'fred', 'active': false }, + * { 'user': 'pebbles', 'active': true } + * ]; + * + * _.findIndex(users, function(o) { return o.user == 'barney'; }); + * // => 0 + * + * // The `_.matches` iteratee shorthand. + * _.findIndex(users, { 'user': 'fred', 'active': false }); + * // => 1 + * + * // The `_.matchesProperty` iteratee shorthand. + * _.findIndex(users, ['active', false]); + * // => 0 + * + * // The `_.property` iteratee shorthand. + * _.findIndex(users, 'active'); + * // => 2 + */function findIndex(array,predicate,fromIndex){var length=array==null?0:array.length;if(!length){return-1}var index=fromIndex==null?0:toInteger(fromIndex);if(index<0){index=nativeMax(length+index,0)}return baseFindIndex(array,baseIteratee(predicate,3),index)}module.exports=findIndex},{"./_baseFindIndex":85,"./_baseIteratee":105,"./toInteger":280}],235:[function(require,module,exports){var baseFlatten=require("./_baseFlatten"); +/** + * Flattens `array` a single level deep. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Array + * @param {Array} array The array to flatten. + * @returns {Array} Returns the new flattened array. + * @example + * + * _.flatten([1, [2, [3, [4]], 5]]); + * // => [1, 2, [3, [4]], 5] + */function flatten(array){var length=array==null?0:array.length;return length?baseFlatten(array,1):[]}module.exports=flatten},{"./_baseFlatten":86}],236:[function(require,module,exports){var arrayEach=require("./_arrayEach"),baseEach=require("./_baseEach"),castFunction=require("./_castFunction"),isArray=require("./isArray"); +/** + * Iterates over elements of `collection` and invokes `iteratee` for each element. + * The iteratee is invoked with three arguments: (value, index|key, collection). + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * **Note:** As with other "Collections" methods, objects with a "length" + * property are iterated like arrays. To avoid this behavior use `_.forIn` + * or `_.forOwn` for object iteration. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @alias each + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Array|Object} Returns `collection`. + * @see _.forEachRight + * @example + * + * _.forEach([1, 2], function(value) { + * console.log(value); + * }); + * // => Logs `1` then `2`. + * + * _.forEach({ 'a': 1, 'b': 2 }, function(value, key) { + * console.log(key); + * }); + * // => Logs 'a' then 'b' (iteration order is not guaranteed). + */function forEach(collection,iteratee){var func=isArray(collection)?arrayEach:baseEach;return func(collection,castFunction(iteratee))}module.exports=forEach},{"./_arrayEach":64,"./_baseEach":82,"./_castFunction":132,"./isArray":243}],237:[function(require,module,exports){var baseFor=require("./_baseFor"),castFunction=require("./_castFunction"),keysIn=require("./keysIn"); +/** + * Iterates over own and inherited enumerable string keyed properties of an + * object and invokes `iteratee` for each property. The iteratee is invoked + * with three arguments: (value, key, object). Iteratee functions may exit + * iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @since 0.3.0 + * @category Object + * @param {Object} object The object to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Object} Returns `object`. + * @see _.forInRight + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.forIn(new Foo, function(value, key) { + * console.log(key); + * }); + * // => Logs 'a', 'b', then 'c' (iteration order is not guaranteed). + */function forIn(object,iteratee){return object==null?object:baseFor(object,castFunction(iteratee),keysIn)}module.exports=forIn},{"./_baseFor":87,"./_castFunction":132,"./keysIn":260}],238:[function(require,module,exports){var baseGet=require("./_baseGet"); +/** + * Gets the value at `path` of `object`. If the resolved value is + * `undefined`, the `defaultValue` is returned in its place. + * + * @static + * @memberOf _ + * @since 3.7.0 + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path of the property to get. + * @param {*} [defaultValue] The value returned for `undefined` resolved values. + * @returns {*} Returns the resolved value. + * @example + * + * var object = { 'a': [{ 'b': { 'c': 3 } }] }; + * + * _.get(object, 'a[0].b.c'); + * // => 3 + * + * _.get(object, ['a', '0', 'b', 'c']); + * // => 3 + * + * _.get(object, 'a.b.c', 'default'); + * // => 'default' + */function get(object,path,defaultValue){var result=object==null?undefined:baseGet(object,path);return result===undefined?defaultValue:result}module.exports=get},{"./_baseGet":89}],239:[function(require,module,exports){var baseHas=require("./_baseHas"),hasPath=require("./_hasPath"); +/** + * Checks if `path` is a direct property of `object`. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path to check. + * @returns {boolean} Returns `true` if `path` exists, else `false`. + * @example + * + * var object = { 'a': { 'b': 2 } }; + * var other = _.create({ 'a': _.create({ 'b': 2 }) }); + * + * _.has(object, 'a'); + * // => true + * + * _.has(object, 'a.b'); + * // => true + * + * _.has(object, ['a', 'b']); + * // => true + * + * _.has(other, 'a'); + * // => false + */function has(object,path){return object!=null&&hasPath(object,path,baseHas)}module.exports=has},{"./_baseHas":93,"./_hasPath":170}],240:[function(require,module,exports){var baseHasIn=require("./_baseHasIn"),hasPath=require("./_hasPath"); +/** + * Checks if `path` is a direct or inherited property of `object`. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path to check. + * @returns {boolean} Returns `true` if `path` exists, else `false`. + * @example + * + * var object = _.create({ 'a': _.create({ 'b': 2 }) }); + * + * _.hasIn(object, 'a'); + * // => true + * + * _.hasIn(object, 'a.b'); + * // => true + * + * _.hasIn(object, ['a', 'b']); + * // => true + * + * _.hasIn(object, 'b'); + * // => false + */function hasIn(object,path){return object!=null&&hasPath(object,path,baseHasIn)}module.exports=hasIn},{"./_baseHasIn":94,"./_hasPath":170}],241:[function(require,module,exports){ +/** + * This method returns the first argument it receives. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Util + * @param {*} value Any value. + * @returns {*} Returns `value`. + * @example + * + * var object = { 'a': 1 }; + * + * console.log(_.identity(object) === object); + * // => true + */ +function identity(value){return value}module.exports=identity},{}],242:[function(require,module,exports){var baseIsArguments=require("./_baseIsArguments"),isObjectLike=require("./isObjectLike"); +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** Built-in value references. */var propertyIsEnumerable=objectProto.propertyIsEnumerable; +/** + * Checks if `value` is likely an `arguments` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an `arguments` object, + * else `false`. + * @example + * + * _.isArguments(function() { return arguments; }()); + * // => true + * + * _.isArguments([1, 2, 3]); + * // => false + */var isArguments=baseIsArguments(function(){return arguments}())?baseIsArguments:function(value){return isObjectLike(value)&&hasOwnProperty.call(value,"callee")&&!propertyIsEnumerable.call(value,"callee")};module.exports=isArguments},{"./_baseIsArguments":96,"./isObjectLike":252}],243:[function(require,module,exports){ +/** + * Checks if `value` is classified as an `Array` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an array, else `false`. + * @example + * + * _.isArray([1, 2, 3]); + * // => true + * + * _.isArray(document.body.children); + * // => false + * + * _.isArray('abc'); + * // => false + * + * _.isArray(_.noop); + * // => false + */ +var isArray=Array.isArray;module.exports=isArray},{}],244:[function(require,module,exports){var isFunction=require("./isFunction"),isLength=require("./isLength"); +/** + * Checks if `value` is array-like. A value is considered array-like if it's + * not a function and has a `value.length` that's an integer greater than or + * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is array-like, else `false`. + * @example + * + * _.isArrayLike([1, 2, 3]); + * // => true + * + * _.isArrayLike(document.body.children); + * // => true + * + * _.isArrayLike('abc'); + * // => true + * + * _.isArrayLike(_.noop); + * // => false + */function isArrayLike(value){return value!=null&&isLength(value.length)&&!isFunction(value)}module.exports=isArrayLike},{"./isFunction":248,"./isLength":249}],245:[function(require,module,exports){var isArrayLike=require("./isArrayLike"),isObjectLike=require("./isObjectLike"); +/** + * This method is like `_.isArrayLike` except that it also checks if `value` + * is an object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an array-like object, + * else `false`. + * @example + * + * _.isArrayLikeObject([1, 2, 3]); + * // => true + * + * _.isArrayLikeObject(document.body.children); + * // => true + * + * _.isArrayLikeObject('abc'); + * // => false + * + * _.isArrayLikeObject(_.noop); + * // => false + */function isArrayLikeObject(value){return isObjectLike(value)&&isArrayLike(value)}module.exports=isArrayLikeObject},{"./isArrayLike":244,"./isObjectLike":252}],246:[function(require,module,exports){var root=require("./_root"),stubFalse=require("./stubFalse"); +/** Detect free variable `exports`. */var freeExports=typeof exports=="object"&&exports&&!exports.nodeType&&exports; +/** Detect free variable `module`. */var freeModule=freeExports&&typeof module=="object"&&module&&!module.nodeType&&module; +/** Detect the popular CommonJS extension `module.exports`. */var moduleExports=freeModule&&freeModule.exports===freeExports; +/** Built-in value references. */var Buffer=moduleExports?root.Buffer:undefined; +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeIsBuffer=Buffer?Buffer.isBuffer:undefined; +/** + * Checks if `value` is a buffer. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a buffer, else `false`. + * @example + * + * _.isBuffer(new Buffer(2)); + * // => true + * + * _.isBuffer(new Uint8Array(2)); + * // => false + */var isBuffer=nativeIsBuffer||stubFalse;module.exports=isBuffer},{"./_root":208,"./stubFalse":278}],247:[function(require,module,exports){var baseKeys=require("./_baseKeys"),getTag=require("./_getTag"),isArguments=require("./isArguments"),isArray=require("./isArray"),isArrayLike=require("./isArrayLike"),isBuffer=require("./isBuffer"),isPrototype=require("./_isPrototype"),isTypedArray=require("./isTypedArray"); +/** `Object#toString` result references. */var mapTag="[object Map]",setTag="[object Set]"; +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** + * Checks if `value` is an empty object, collection, map, or set. + * + * Objects are considered empty if they have no own enumerable string keyed + * properties. + * + * Array-like values such as `arguments` objects, arrays, buffers, strings, or + * jQuery-like collections are considered empty if they have a `length` of `0`. + * Similarly, maps and sets are considered empty if they have a `size` of `0`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is empty, else `false`. + * @example + * + * _.isEmpty(null); + * // => true + * + * _.isEmpty(true); + * // => true + * + * _.isEmpty(1); + * // => true + * + * _.isEmpty([1, 2, 3]); + * // => false + * + * _.isEmpty({ 'a': 1 }); + * // => false + */function isEmpty(value){if(value==null){return true}if(isArrayLike(value)&&(isArray(value)||typeof value=="string"||typeof value.splice=="function"||isBuffer(value)||isTypedArray(value)||isArguments(value))){return!value.length}var tag=getTag(value);if(tag==mapTag||tag==setTag){return!value.size}if(isPrototype(value)){return!baseKeys(value).length}for(var key in value){if(hasOwnProperty.call(value,key)){return false}}return true}module.exports=isEmpty},{"./_baseKeys":106,"./_getTag":168,"./_isPrototype":186,"./isArguments":242,"./isArray":243,"./isArrayLike":244,"./isBuffer":246,"./isTypedArray":257}],248:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isObject=require("./isObject"); +/** `Object#toString` result references. */var asyncTag="[object AsyncFunction]",funcTag="[object Function]",genTag="[object GeneratorFunction]",proxyTag="[object Proxy]"; +/** + * Checks if `value` is classified as a `Function` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a function, else `false`. + * @example + * + * _.isFunction(_); + * // => true + * + * _.isFunction(/abc/); + * // => false + */function isFunction(value){if(!isObject(value)){return false} +// The use of `Object#toString` avoids issues with the `typeof` operator +// in Safari 9 which returns 'object' for typed arrays and other constructors. +var tag=baseGetTag(value);return tag==funcTag||tag==genTag||tag==asyncTag||tag==proxyTag}module.exports=isFunction},{"./_baseGetTag":91,"./isObject":251}],249:[function(require,module,exports){ +/** Used as references for various `Number` constants. */ +var MAX_SAFE_INTEGER=9007199254740991; +/** + * Checks if `value` is a valid array-like length. + * + * **Note:** This method is loosely based on + * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength). + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a valid length, else `false`. + * @example + * + * _.isLength(3); + * // => true + * + * _.isLength(Number.MIN_VALUE); + * // => false + * + * _.isLength(Infinity); + * // => false + * + * _.isLength('3'); + * // => false + */function isLength(value){return typeof value=="number"&&value>-1&&value%1==0&&value<=MAX_SAFE_INTEGER}module.exports=isLength},{}],250:[function(require,module,exports){var baseIsMap=require("./_baseIsMap"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsMap=nodeUtil&&nodeUtil.isMap; +/** + * Checks if `value` is classified as a `Map` object. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a map, else `false`. + * @example + * + * _.isMap(new Map); + * // => true + * + * _.isMap(new WeakMap); + * // => false + */var isMap=nodeIsMap?baseUnary(nodeIsMap):baseIsMap;module.exports=isMap},{"./_baseIsMap":99,"./_baseUnary":127,"./_nodeUtil":204}],251:[function(require,module,exports){ +/** + * Checks if `value` is the + * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) + * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(_.noop); + * // => true + * + * _.isObject(null); + * // => false + */ +function isObject(value){var type=typeof value;return value!=null&&(type=="object"||type=="function")}module.exports=isObject},{}],252:[function(require,module,exports){ +/** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ +function isObjectLike(value){return value!=null&&typeof value=="object"}module.exports=isObjectLike},{}],253:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),getPrototype=require("./_getPrototype"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var objectTag="[object Object]"; +/** Used for built-in method references. */var funcProto=Function.prototype,objectProto=Object.prototype; +/** Used to resolve the decompiled source of functions. */var funcToString=funcProto.toString; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** Used to infer the `Object` constructor. */var objectCtorString=funcToString.call(Object); +/** + * Checks if `value` is a plain object, that is, an object created by the + * `Object` constructor or one with a `[[Prototype]]` of `null`. + * + * @static + * @memberOf _ + * @since 0.8.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + * @example + * + * function Foo() { + * this.a = 1; + * } + * + * _.isPlainObject(new Foo); + * // => false + * + * _.isPlainObject([1, 2, 3]); + * // => false + * + * _.isPlainObject({ 'x': 0, 'y': 0 }); + * // => true + * + * _.isPlainObject(Object.create(null)); + * // => true + */function isPlainObject(value){if(!isObjectLike(value)||baseGetTag(value)!=objectTag){return false}var proto=getPrototype(value);if(proto===null){return true}var Ctor=hasOwnProperty.call(proto,"constructor")&&proto.constructor;return typeof Ctor=="function"&&Ctor instanceof Ctor&&funcToString.call(Ctor)==objectCtorString}module.exports=isPlainObject},{"./_baseGetTag":91,"./_getPrototype":164,"./isObjectLike":252}],254:[function(require,module,exports){var baseIsSet=require("./_baseIsSet"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsSet=nodeUtil&&nodeUtil.isSet; +/** + * Checks if `value` is classified as a `Set` object. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a set, else `false`. + * @example + * + * _.isSet(new Set); + * // => true + * + * _.isSet(new WeakSet); + * // => false + */var isSet=nodeIsSet?baseUnary(nodeIsSet):baseIsSet;module.exports=isSet},{"./_baseIsSet":103,"./_baseUnary":127,"./_nodeUtil":204}],255:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isArray=require("./isArray"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var stringTag="[object String]"; +/** + * Checks if `value` is classified as a `String` primitive or object. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a string, else `false`. + * @example + * + * _.isString('abc'); + * // => true + * + * _.isString(1); + * // => false + */function isString(value){return typeof value=="string"||!isArray(value)&&isObjectLike(value)&&baseGetTag(value)==stringTag}module.exports=isString},{"./_baseGetTag":91,"./isArray":243,"./isObjectLike":252}],256:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var symbolTag="[object Symbol]"; +/** + * Checks if `value` is classified as a `Symbol` primitive or object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. + * @example + * + * _.isSymbol(Symbol.iterator); + * // => true + * + * _.isSymbol('abc'); + * // => false + */function isSymbol(value){return typeof value=="symbol"||isObjectLike(value)&&baseGetTag(value)==symbolTag}module.exports=isSymbol},{"./_baseGetTag":91,"./isObjectLike":252}],257:[function(require,module,exports){var baseIsTypedArray=require("./_baseIsTypedArray"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsTypedArray=nodeUtil&&nodeUtil.isTypedArray; +/** + * Checks if `value` is classified as a typed array. + * + * @static + * @memberOf _ + * @since 3.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. + * @example + * + * _.isTypedArray(new Uint8Array); + * // => true + * + * _.isTypedArray([]); + * // => false + */var isTypedArray=nodeIsTypedArray?baseUnary(nodeIsTypedArray):baseIsTypedArray;module.exports=isTypedArray},{"./_baseIsTypedArray":104,"./_baseUnary":127,"./_nodeUtil":204}],258:[function(require,module,exports){ +/** + * Checks if `value` is `undefined`. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is `undefined`, else `false`. + * @example + * + * _.isUndefined(void 0); + * // => true + * + * _.isUndefined(null); + * // => false + */ +function isUndefined(value){return value===undefined}module.exports=isUndefined},{}],259:[function(require,module,exports){var arrayLikeKeys=require("./_arrayLikeKeys"),baseKeys=require("./_baseKeys"),isArrayLike=require("./isArrayLike"); +/** + * Creates an array of the own enumerable property names of `object`. + * + * **Note:** Non-object values are coerced to objects. See the + * [ES spec](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) + * for more details. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.keys(new Foo); + * // => ['a', 'b'] (iteration order is not guaranteed) + * + * _.keys('hi'); + * // => ['0', '1'] + */function keys(object){return isArrayLike(object)?arrayLikeKeys(object):baseKeys(object)}module.exports=keys},{"./_arrayLikeKeys":68,"./_baseKeys":106,"./isArrayLike":244}],260:[function(require,module,exports){var arrayLikeKeys=require("./_arrayLikeKeys"),baseKeysIn=require("./_baseKeysIn"),isArrayLike=require("./isArrayLike"); +/** + * Creates an array of the own and inherited enumerable property names of `object`. + * + * **Note:** Non-object values are coerced to objects. + * + * @static + * @memberOf _ + * @since 3.0.0 + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.keysIn(new Foo); + * // => ['a', 'b', 'c'] (iteration order is not guaranteed) + */function keysIn(object){return isArrayLike(object)?arrayLikeKeys(object,true):baseKeysIn(object)}module.exports=keysIn},{"./_arrayLikeKeys":68,"./_baseKeysIn":107,"./isArrayLike":244}],261:[function(require,module,exports){ +/** + * Gets the last element of `array`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Array + * @param {Array} array The array to query. + * @returns {*} Returns the last element of `array`. + * @example + * + * _.last([1, 2, 3]); + * // => 3 + */ +function last(array){var length=array==null?0:array.length;return length?array[length-1]:undefined}module.exports=last},{}],262:[function(require,module,exports){var arrayMap=require("./_arrayMap"),baseIteratee=require("./_baseIteratee"),baseMap=require("./_baseMap"),isArray=require("./isArray"); +/** + * Creates an array of values by running each element in `collection` thru + * `iteratee`. The iteratee is invoked with three arguments: + * (value, index|key, collection). + * + * Many lodash methods are guarded to work as iteratees for methods like + * `_.every`, `_.filter`, `_.map`, `_.mapValues`, `_.reject`, and `_.some`. + * + * The guarded methods are: + * `ary`, `chunk`, `curry`, `curryRight`, `drop`, `dropRight`, `every`, + * `fill`, `invert`, `parseInt`, `random`, `range`, `rangeRight`, `repeat`, + * `sampleSize`, `slice`, `some`, `sortBy`, `split`, `take`, `takeRight`, + * `template`, `trim`, `trimEnd`, `trimStart`, and `words` + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Array} Returns the new mapped array. + * @example + * + * function square(n) { + * return n * n; + * } + * + * _.map([4, 8], square); + * // => [16, 64] + * + * _.map({ 'a': 4, 'b': 8 }, square); + * // => [16, 64] (iteration order is not guaranteed) + * + * var users = [ + * { 'user': 'barney' }, + * { 'user': 'fred' } + * ]; + * + * // The `_.property` iteratee shorthand. + * _.map(users, 'user'); + * // => ['barney', 'fred'] + */function map(collection,iteratee){var func=isArray(collection)?arrayMap:baseMap;return func(collection,baseIteratee(iteratee,3))}module.exports=map},{"./_arrayMap":69,"./_baseIteratee":105,"./_baseMap":109,"./isArray":243}],263:[function(require,module,exports){var baseAssignValue=require("./_baseAssignValue"),baseForOwn=require("./_baseForOwn"),baseIteratee=require("./_baseIteratee"); +/** + * Creates an object with the same keys as `object` and values generated + * by running each own enumerable string keyed property of `object` thru + * `iteratee`. The iteratee is invoked with three arguments: + * (value, key, object). + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Object + * @param {Object} object The object to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Object} Returns the new mapped object. + * @see _.mapKeys + * @example + * + * var users = { + * 'fred': { 'user': 'fred', 'age': 40 }, + * 'pebbles': { 'user': 'pebbles', 'age': 1 } + * }; + * + * _.mapValues(users, function(o) { return o.age; }); + * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed) + * + * // The `_.property` iteratee shorthand. + * _.mapValues(users, 'age'); + * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed) + */function mapValues(object,iteratee){var result={};iteratee=baseIteratee(iteratee,3);baseForOwn(object,function(value,key,object){baseAssignValue(result,key,iteratee(value,key,object))});return result}module.exports=mapValues},{"./_baseAssignValue":79,"./_baseForOwn":88,"./_baseIteratee":105}],264:[function(require,module,exports){var baseExtremum=require("./_baseExtremum"),baseGt=require("./_baseGt"),identity=require("./identity"); +/** + * Computes the maximum value of `array`. If `array` is empty or falsey, + * `undefined` is returned. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Math + * @param {Array} array The array to iterate over. + * @returns {*} Returns the maximum value. + * @example + * + * _.max([4, 2, 8, 6]); + * // => 8 + * + * _.max([]); + * // => undefined + */function max(array){return array&&array.length?baseExtremum(array,identity,baseGt):undefined}module.exports=max},{"./_baseExtremum":83,"./_baseGt":92,"./identity":241}],265:[function(require,module,exports){var MapCache=require("./_MapCache"); +/** Error message constants. */var FUNC_ERROR_TEXT="Expected a function"; +/** + * Creates a function that memoizes the result of `func`. If `resolver` is + * provided, it determines the cache key for storing the result based on the + * arguments provided to the memoized function. By default, the first argument + * provided to the memoized function is used as the map cache key. The `func` + * is invoked with the `this` binding of the memoized function. + * + * **Note:** The cache is exposed as the `cache` property on the memoized + * function. Its creation may be customized by replacing the `_.memoize.Cache` + * constructor with one whose instances implement the + * [`Map`](http://ecma-international.org/ecma-262/7.0/#sec-properties-of-the-map-prototype-object) + * method interface of `clear`, `delete`, `get`, `has`, and `set`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to have its output memoized. + * @param {Function} [resolver] The function to resolve the cache key. + * @returns {Function} Returns the new memoized function. + * @example + * + * var object = { 'a': 1, 'b': 2 }; + * var other = { 'c': 3, 'd': 4 }; + * + * var values = _.memoize(_.values); + * values(object); + * // => [1, 2] + * + * values(other); + * // => [3, 4] + * + * object.a = 2; + * values(object); + * // => [1, 2] + * + * // Modify the result cache. + * values.cache.set(object, ['a', 'b']); + * values(object); + * // => ['a', 'b'] + * + * // Replace `_.memoize.Cache`. + * _.memoize.Cache = WeakMap; + */function memoize(func,resolver){if(typeof func!="function"||resolver!=null&&typeof resolver!="function"){throw new TypeError(FUNC_ERROR_TEXT)}var memoized=function(){var args=arguments,key=resolver?resolver.apply(this,args):args[0],cache=memoized.cache;if(cache.has(key)){return cache.get(key)}var result=func.apply(this,args);memoized.cache=cache.set(key,result)||cache;return result};memoized.cache=new(memoize.Cache||MapCache);return memoized} +// Expose `MapCache`. +memoize.Cache=MapCache;module.exports=memoize},{"./_MapCache":55}],266:[function(require,module,exports){var baseMerge=require("./_baseMerge"),createAssigner=require("./_createAssigner"); +/** + * This method is like `_.assign` except that it recursively merges own and + * inherited enumerable string keyed properties of source objects into the + * destination object. Source properties that resolve to `undefined` are + * skipped if a destination value exists. Array and plain object properties + * are merged recursively. Other objects and value types are overridden by + * assignment. Source objects are applied from left to right. Subsequent + * sources overwrite property assignments of previous sources. + * + * **Note:** This method mutates `object`. + * + * @static + * @memberOf _ + * @since 0.5.0 + * @category Object + * @param {Object} object The destination object. + * @param {...Object} [sources] The source objects. + * @returns {Object} Returns `object`. + * @example + * + * var object = { + * 'a': [{ 'b': 2 }, { 'd': 4 }] + * }; + * + * var other = { + * 'a': [{ 'c': 3 }, { 'e': 5 }] + * }; + * + * _.merge(object, other); + * // => { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] } + */var merge=createAssigner(function(object,source,srcIndex){baseMerge(object,source,srcIndex)});module.exports=merge},{"./_baseMerge":112,"./_createAssigner":147}],267:[function(require,module,exports){var baseExtremum=require("./_baseExtremum"),baseLt=require("./_baseLt"),identity=require("./identity"); +/** + * Computes the minimum value of `array`. If `array` is empty or falsey, + * `undefined` is returned. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Math + * @param {Array} array The array to iterate over. + * @returns {*} Returns the minimum value. + * @example + * + * _.min([4, 2, 8, 6]); + * // => 2 + * + * _.min([]); + * // => undefined + */function min(array){return array&&array.length?baseExtremum(array,identity,baseLt):undefined}module.exports=min},{"./_baseExtremum":83,"./_baseLt":108,"./identity":241}],268:[function(require,module,exports){var baseExtremum=require("./_baseExtremum"),baseIteratee=require("./_baseIteratee"),baseLt=require("./_baseLt"); +/** + * This method is like `_.min` except that it accepts `iteratee` which is + * invoked for each element in `array` to generate the criterion by which + * the value is ranked. The iteratee is invoked with one argument: (value). + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Math + * @param {Array} array The array to iterate over. + * @param {Function} [iteratee=_.identity] The iteratee invoked per element. + * @returns {*} Returns the minimum value. + * @example + * + * var objects = [{ 'n': 1 }, { 'n': 2 }]; + * + * _.minBy(objects, function(o) { return o.n; }); + * // => { 'n': 1 } + * + * // The `_.property` iteratee shorthand. + * _.minBy(objects, 'n'); + * // => { 'n': 1 } + */function minBy(array,iteratee){return array&&array.length?baseExtremum(array,baseIteratee(iteratee,2),baseLt):undefined}module.exports=minBy},{"./_baseExtremum":83,"./_baseIteratee":105,"./_baseLt":108}],269:[function(require,module,exports){ +/** + * This method returns `undefined`. + * + * @static + * @memberOf _ + * @since 2.3.0 + * @category Util + * @example + * + * _.times(2, _.noop); + * // => [undefined, undefined] + */ +function noop(){ +// No operation performed. +}module.exports=noop},{}],270:[function(require,module,exports){var root=require("./_root"); +/** + * Gets the timestamp of the number of milliseconds that have elapsed since + * the Unix epoch (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Date + * @returns {number} Returns the timestamp. + * @example + * + * _.defer(function(stamp) { + * console.log(_.now() - stamp); + * }, _.now()); + * // => Logs the number of milliseconds it took for the deferred invocation. + */var now=function(){return root.Date.now()};module.exports=now},{"./_root":208}],271:[function(require,module,exports){var basePick=require("./_basePick"),flatRest=require("./_flatRest"); +/** + * Creates an object composed of the picked `object` properties. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The source object. + * @param {...(string|string[])} [paths] The property paths to pick. + * @returns {Object} Returns the new object. + * @example + * + * var object = { 'a': 1, 'b': '2', 'c': 3 }; + * + * _.pick(object, ['a', 'c']); + * // => { 'a': 1, 'c': 3 } + */var pick=flatRest(function(object,paths){return object==null?{}:basePick(object,paths)});module.exports=pick},{"./_basePick":115,"./_flatRest":157}],272:[function(require,module,exports){var baseProperty=require("./_baseProperty"),basePropertyDeep=require("./_basePropertyDeep"),isKey=require("./_isKey"),toKey=require("./_toKey"); +/** + * Creates a function that returns the value at `path` of a given object. + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Util + * @param {Array|string} path The path of the property to get. + * @returns {Function} Returns the new accessor function. + * @example + * + * var objects = [ + * { 'a': { 'b': 2 } }, + * { 'a': { 'b': 1 } } + * ]; + * + * _.map(objects, _.property('a.b')); + * // => [2, 1] + * + * _.map(_.sortBy(objects, _.property(['a', 'b'])), 'a.b'); + * // => [1, 2] + */function property(path){return isKey(path)?baseProperty(toKey(path)):basePropertyDeep(path)}module.exports=property},{"./_baseProperty":117,"./_basePropertyDeep":118,"./_isKey":183,"./_toKey":223}],273:[function(require,module,exports){var createRange=require("./_createRange"); +/** + * Creates an array of numbers (positive and/or negative) progressing from + * `start` up to, but not including, `end`. A step of `-1` is used if a negative + * `start` is specified without an `end` or `step`. If `end` is not specified, + * it's set to `start` with `start` then set to `0`. + * + * **Note:** JavaScript follows the IEEE-754 standard for resolving + * floating-point values which can produce unexpected results. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Util + * @param {number} [start=0] The start of the range. + * @param {number} end The end of the range. + * @param {number} [step=1] The value to increment or decrement by. + * @returns {Array} Returns the range of numbers. + * @see _.inRange, _.rangeRight + * @example + * + * _.range(4); + * // => [0, 1, 2, 3] + * + * _.range(-4); + * // => [0, -1, -2, -3] + * + * _.range(1, 5); + * // => [1, 2, 3, 4] + * + * _.range(0, 20, 5); + * // => [0, 5, 10, 15] + * + * _.range(0, -4, -1); + * // => [0, -1, -2, -3] + * + * _.range(1, 4, 0); + * // => [1, 1, 1] + * + * _.range(0); + * // => [] + */var range=createRange();module.exports=range},{"./_createRange":151}],274:[function(require,module,exports){var arrayReduce=require("./_arrayReduce"),baseEach=require("./_baseEach"),baseIteratee=require("./_baseIteratee"),baseReduce=require("./_baseReduce"),isArray=require("./isArray"); +/** + * Reduces `collection` to a value which is the accumulated result of running + * each element in `collection` thru `iteratee`, where each successive + * invocation is supplied the return value of the previous. If `accumulator` + * is not given, the first element of `collection` is used as the initial + * value. The iteratee is invoked with four arguments: + * (accumulator, value, index|key, collection). + * + * Many lodash methods are guarded to work as iteratees for methods like + * `_.reduce`, `_.reduceRight`, and `_.transform`. + * + * The guarded methods are: + * `assign`, `defaults`, `defaultsDeep`, `includes`, `merge`, `orderBy`, + * and `sortBy` + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @param {*} [accumulator] The initial value. + * @returns {*} Returns the accumulated value. + * @see _.reduceRight + * @example + * + * _.reduce([1, 2], function(sum, n) { + * return sum + n; + * }, 0); + * // => 3 + * + * _.reduce({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { + * (result[value] || (result[value] = [])).push(key); + * return result; + * }, {}); + * // => { '1': ['a', 'c'], '2': ['b'] } (iteration order is not guaranteed) + */function reduce(collection,iteratee,accumulator){var func=isArray(collection)?arrayReduce:baseReduce,initAccum=arguments.length<3;return func(collection,baseIteratee(iteratee,4),accumulator,initAccum,baseEach)}module.exports=reduce},{"./_arrayReduce":71,"./_baseEach":82,"./_baseIteratee":105,"./_baseReduce":120,"./isArray":243}],275:[function(require,module,exports){var baseKeys=require("./_baseKeys"),getTag=require("./_getTag"),isArrayLike=require("./isArrayLike"),isString=require("./isString"),stringSize=require("./_stringSize"); +/** `Object#toString` result references. */var mapTag="[object Map]",setTag="[object Set]"; +/** + * Gets the size of `collection` by returning its length for array-like + * values or the number of own enumerable string keyed properties for objects. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object|string} collection The collection to inspect. + * @returns {number} Returns the collection size. + * @example + * + * _.size([1, 2, 3]); + * // => 3 + * + * _.size({ 'a': 1, 'b': 2 }); + * // => 2 + * + * _.size('pebbles'); + * // => 7 + */function size(collection){if(collection==null){return 0}if(isArrayLike(collection)){return isString(collection)?stringSize(collection):collection.length}var tag=getTag(collection);if(tag==mapTag||tag==setTag){return collection.size}return baseKeys(collection).length}module.exports=size},{"./_baseKeys":106,"./_getTag":168,"./_stringSize":221,"./isArrayLike":244,"./isString":255}],276:[function(require,module,exports){var baseFlatten=require("./_baseFlatten"),baseOrderBy=require("./_baseOrderBy"),baseRest=require("./_baseRest"),isIterateeCall=require("./_isIterateeCall"); +/** + * Creates an array of elements, sorted in ascending order by the results of + * running each element in a collection thru each iteratee. This method + * performs a stable sort, that is, it preserves the original sort order of + * equal elements. The iteratees are invoked with one argument: (value). + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {...(Function|Function[])} [iteratees=[_.identity]] + * The iteratees to sort by. + * @returns {Array} Returns the new sorted array. + * @example + * + * var users = [ + * { 'user': 'fred', 'age': 48 }, + * { 'user': 'barney', 'age': 36 }, + * { 'user': 'fred', 'age': 40 }, + * { 'user': 'barney', 'age': 34 } + * ]; + * + * _.sortBy(users, [function(o) { return o.user; }]); + * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 40]] + * + * _.sortBy(users, ['user', 'age']); + * // => objects for [['barney', 34], ['barney', 36], ['fred', 40], ['fred', 48]] + */var sortBy=baseRest(function(collection,iteratees){if(collection==null){return[]}var length=iteratees.length;if(length>1&&isIterateeCall(collection,iteratees[0],iteratees[1])){iteratees=[]}else if(length>2&&isIterateeCall(iteratees[0],iteratees[1],iteratees[2])){iteratees=[iteratees[0]]}return baseOrderBy(collection,baseFlatten(iteratees,1),[])});module.exports=sortBy},{"./_baseFlatten":86,"./_baseOrderBy":114,"./_baseRest":121,"./_isIterateeCall":182}],277:[function(require,module,exports){ +/** + * This method returns a new empty array. + * + * @static + * @memberOf _ + * @since 4.13.0 + * @category Util + * @returns {Array} Returns the new empty array. + * @example + * + * var arrays = _.times(2, _.stubArray); + * + * console.log(arrays); + * // => [[], []] + * + * console.log(arrays[0] === arrays[1]); + * // => false + */ +function stubArray(){return[]}module.exports=stubArray},{}],278:[function(require,module,exports){ +/** + * This method returns `false`. + * + * @static + * @memberOf _ + * @since 4.13.0 + * @category Util + * @returns {boolean} Returns `false`. + * @example + * + * _.times(2, _.stubFalse); + * // => [false, false] + */ +function stubFalse(){return false}module.exports=stubFalse},{}],279:[function(require,module,exports){var toNumber=require("./toNumber"); +/** Used as references for various `Number` constants. */var INFINITY=1/0,MAX_INTEGER=17976931348623157e292; +/** + * Converts `value` to a finite number. + * + * @static + * @memberOf _ + * @since 4.12.0 + * @category Lang + * @param {*} value The value to convert. + * @returns {number} Returns the converted number. + * @example + * + * _.toFinite(3.2); + * // => 3.2 + * + * _.toFinite(Number.MIN_VALUE); + * // => 5e-324 + * + * _.toFinite(Infinity); + * // => 1.7976931348623157e+308 + * + * _.toFinite('3.2'); + * // => 3.2 + */function toFinite(value){if(!value){return value===0?value:0}value=toNumber(value);if(value===INFINITY||value===-INFINITY){var sign=value<0?-1:1;return sign*MAX_INTEGER}return value===value?value:0}module.exports=toFinite},{"./toNumber":281}],280:[function(require,module,exports){var toFinite=require("./toFinite"); +/** + * Converts `value` to an integer. + * + * **Note:** This method is loosely based on + * [`ToInteger`](http://www.ecma-international.org/ecma-262/7.0/#sec-tointeger). + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to convert. + * @returns {number} Returns the converted integer. + * @example + * + * _.toInteger(3.2); + * // => 3 + * + * _.toInteger(Number.MIN_VALUE); + * // => 0 + * + * _.toInteger(Infinity); + * // => 1.7976931348623157e+308 + * + * _.toInteger('3.2'); + * // => 3 + */function toInteger(value){var result=toFinite(value),remainder=result%1;return result===result?remainder?result-remainder:result:0}module.exports=toInteger},{"./toFinite":279}],281:[function(require,module,exports){var isObject=require("./isObject"),isSymbol=require("./isSymbol"); +/** Used as references for various `Number` constants. */var NAN=0/0; +/** Used to match leading and trailing whitespace. */var reTrim=/^\s+|\s+$/g; +/** Used to detect bad signed hexadecimal string values. */var reIsBadHex=/^[-+]0x[0-9a-f]+$/i; +/** Used to detect binary string values. */var reIsBinary=/^0b[01]+$/i; +/** Used to detect octal string values. */var reIsOctal=/^0o[0-7]+$/i; +/** Built-in method references without a dependency on `root`. */var freeParseInt=parseInt; +/** + * Converts `value` to a number. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to process. + * @returns {number} Returns the number. + * @example + * + * _.toNumber(3.2); + * // => 3.2 + * + * _.toNumber(Number.MIN_VALUE); + * // => 5e-324 + * + * _.toNumber(Infinity); + * // => Infinity + * + * _.toNumber('3.2'); + * // => 3.2 + */function toNumber(value){if(typeof value=="number"){return value}if(isSymbol(value)){return NAN}if(isObject(value)){var other=typeof value.valueOf=="function"?value.valueOf():value;value=isObject(other)?other+"":other}if(typeof value!="string"){return value===0?value:+value}value=value.replace(reTrim,"");var isBinary=reIsBinary.test(value);return isBinary||reIsOctal.test(value)?freeParseInt(value.slice(2),isBinary?2:8):reIsBadHex.test(value)?NAN:+value}module.exports=toNumber},{"./isObject":251,"./isSymbol":256}],282:[function(require,module,exports){var copyObject=require("./_copyObject"),keysIn=require("./keysIn"); +/** + * Converts `value` to a plain object flattening inherited enumerable string + * keyed properties of `value` to own properties of the plain object. + * + * @static + * @memberOf _ + * @since 3.0.0 + * @category Lang + * @param {*} value The value to convert. + * @returns {Object} Returns the converted plain object. + * @example + * + * function Foo() { + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.assign({ 'a': 1 }, new Foo); + * // => { 'a': 1, 'b': 2 } + * + * _.assign({ 'a': 1 }, _.toPlainObject(new Foo)); + * // => { 'a': 1, 'b': 2, 'c': 3 } + */function toPlainObject(value){return copyObject(value,keysIn(value))}module.exports=toPlainObject},{"./_copyObject":143,"./keysIn":260}],283:[function(require,module,exports){var baseToString=require("./_baseToString"); +/** + * Converts `value` to a string. An empty string is returned for `null` + * and `undefined` values. The sign of `-0` is preserved. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + * @example + * + * _.toString(null); + * // => '' + * + * _.toString(-0); + * // => '-0' + * + * _.toString([1, 2, 3]); + * // => '1,2,3' + */function toString(value){return value==null?"":baseToString(value)}module.exports=toString},{"./_baseToString":126}],284:[function(require,module,exports){var arrayEach=require("./_arrayEach"),baseCreate=require("./_baseCreate"),baseForOwn=require("./_baseForOwn"),baseIteratee=require("./_baseIteratee"),getPrototype=require("./_getPrototype"),isArray=require("./isArray"),isBuffer=require("./isBuffer"),isFunction=require("./isFunction"),isObject=require("./isObject"),isTypedArray=require("./isTypedArray"); +/** + * An alternative to `_.reduce`; this method transforms `object` to a new + * `accumulator` object which is the result of running each of its own + * enumerable string keyed properties thru `iteratee`, with each invocation + * potentially mutating the `accumulator` object. If `accumulator` is not + * provided, a new object with the same `[[Prototype]]` will be used. The + * iteratee is invoked with four arguments: (accumulator, value, key, object). + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @since 1.3.0 + * @category Object + * @param {Object} object The object to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @param {*} [accumulator] The custom accumulator value. + * @returns {*} Returns the accumulated value. + * @example + * + * _.transform([2, 3, 4], function(result, n) { + * result.push(n *= n); + * return n % 2 == 0; + * }, []); + * // => [4, 9] + * + * _.transform({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { + * (result[value] || (result[value] = [])).push(key); + * }, {}); + * // => { '1': ['a', 'c'], '2': ['b'] } + */function transform(object,iteratee,accumulator){var isArr=isArray(object),isArrLike=isArr||isBuffer(object)||isTypedArray(object);iteratee=baseIteratee(iteratee,4);if(accumulator==null){var Ctor=object&&object.constructor;if(isArrLike){accumulator=isArr?new Ctor:[]}else if(isObject(object)){accumulator=isFunction(Ctor)?baseCreate(getPrototype(object)):{}}else{accumulator={}}}(isArrLike?arrayEach:baseForOwn)(object,function(value,index,object){return iteratee(accumulator,value,index,object)});return accumulator}module.exports=transform},{"./_arrayEach":64,"./_baseCreate":81,"./_baseForOwn":88,"./_baseIteratee":105,"./_getPrototype":164,"./isArray":243,"./isBuffer":246,"./isFunction":248,"./isObject":251,"./isTypedArray":257}],285:[function(require,module,exports){var baseFlatten=require("./_baseFlatten"),baseRest=require("./_baseRest"),baseUniq=require("./_baseUniq"),isArrayLikeObject=require("./isArrayLikeObject"); +/** + * Creates an array of unique values, in order, from all given arrays using + * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) + * for equality comparisons. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Array + * @param {...Array} [arrays] The arrays to inspect. + * @returns {Array} Returns the new array of combined values. + * @example + * + * _.union([2], [1, 2]); + * // => [2, 1] + */var union=baseRest(function(arrays){return baseUniq(baseFlatten(arrays,1,isArrayLikeObject,true))});module.exports=union},{"./_baseFlatten":86,"./_baseRest":121,"./_baseUniq":128,"./isArrayLikeObject":245}],286:[function(require,module,exports){var toString=require("./toString"); +/** Used to generate unique IDs. */var idCounter=0; +/** + * Generates a unique ID. If `prefix` is given, the ID is appended to it. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Util + * @param {string} [prefix=''] The value to prefix the ID with. + * @returns {string} Returns the unique ID. + * @example + * + * _.uniqueId('contact_'); + * // => 'contact_104' + * + * _.uniqueId(); + * // => '105' + */function uniqueId(prefix){var id=++idCounter;return toString(prefix)+id}module.exports=uniqueId},{"./toString":283}],287:[function(require,module,exports){var baseValues=require("./_baseValues"),keys=require("./keys"); +/** + * Creates an array of the own enumerable string keyed property values of `object`. + * + * **Note:** Non-object values are coerced to objects. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property values. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.values(new Foo); + * // => [1, 2] (iteration order is not guaranteed) + * + * _.values('hi'); + * // => ['h', 'i'] + */function values(object){return object==null?[]:baseValues(object,keys(object))}module.exports=values},{"./_baseValues":129,"./keys":259}],288:[function(require,module,exports){var assignValue=require("./_assignValue"),baseZipObject=require("./_baseZipObject"); +/** + * This method is like `_.fromPairs` except that it accepts two arrays, + * one of property identifiers and one of corresponding values. + * + * @static + * @memberOf _ + * @since 0.4.0 + * @category Array + * @param {Array} [props=[]] The property identifiers. + * @param {Array} [values=[]] The property values. + * @returns {Object} Returns the new object. + * @example + * + * _.zipObject(['a', 'b'], [1, 2]); + * // => { 'a': 1, 'b': 2 } + */function zipObject(props,values){return baseZipObject(props||[],values||[],assignValue)}module.exports=zipObject},{"./_assignValue":75,"./_baseZipObject":130}]},{},[1])(1)}); diff --git a/js/kundenkarte.js b/js/kundenkarte.js new file mode 100755 index 0000000..6c795cb --- /dev/null +++ b/js/kundenkarte.js @@ -0,0 +1,11750 @@ +/** + * KundenKarte Module JavaScript + * Copyright (C) 2026 Alles Watt lauft + */ + +(function() { + 'use strict'; + + // Namespace + window.KundenKarte = window.KundenKarte || {}; + + // =========================================== + // Global Dialog Functions (replacing browser dialogs) + // =========================================== + + // Escape HTML helper + function escapeHtml(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Global alert dialog + KundenKarte.showAlert = function(title, message, onClose) { + $('#kundenkarte-alert-dialog').remove(); + + var html = '
'; + html += '
'; + html += '

' + escapeHtml(title) + '

'; + html += '×
'; + html += '
'; + html += '

' + escapeHtml(message) + '

'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-alert-dialog').addClass('visible'); + $('#alert-ok').focus(); + + var closeDialog = function() { + $('#kundenkarte-alert-dialog').remove(); + $(document).off('keydown.alertDialog'); + if (typeof onClose === 'function') onClose(); + }; + + $('#alert-ok, #kundenkarte-alert-dialog .kundenkarte-modal-close').on('click', closeDialog); + $(document).on('keydown.alertDialog', function(e) { + if (e.key === 'Escape' || e.key === 'Enter') closeDialog(); + }); + }; + + // Global confirm dialog + KundenKarte.showConfirm = function(title, message, onConfirm, onCancel) { + $('#kundenkarte-confirm-dialog').remove(); + + var html = '
'; + html += '
'; + html += '

' + escapeHtml(title) + '

'; + html += '×
'; + html += '
'; + html += '

' + escapeHtml(message) + '

'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-confirm-dialog').addClass('visible'); + $('#confirm-yes').focus(); + + $('#confirm-yes').on('click', function() { + $('#kundenkarte-confirm-dialog').remove(); + $(document).off('keydown.confirmDialog'); + if (typeof onConfirm === 'function') onConfirm(); + }); + + $('#confirm-no, #kundenkarte-confirm-dialog .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-confirm-dialog').remove(); + $(document).off('keydown.confirmDialog'); + if (typeof onCancel === 'function') onCancel(); + }); + + $(document).on('keydown.confirmDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-confirm-dialog').remove(); + $(document).off('keydown.confirmDialog'); + if (typeof onCancel === 'function') onCancel(); + } else if (e.key === 'Enter') { + $('#kundenkarte-confirm-dialog').remove(); + $(document).off('keydown.confirmDialog'); + if (typeof onConfirm === 'function') onConfirm(); + } + }); + }; + + // Global error display with details + KundenKarte.showError = function(title, message, details) { + $('#kundenkarte-error-dialog').remove(); + + var html = '
'; + html += '
'; + html += '

' + escapeHtml(title || 'Fehler') + '

'; + html += '×
'; + html += '
'; + html += '

' + escapeHtml(message || 'Ein unbekannter Fehler ist aufgetreten.') + '

'; + if (details) { + html += '
Technische Details'; + html += '
' + escapeHtml(details) + '
'; + html += '
'; + } + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-error-dialog').addClass('visible'); + $('#error-ok').focus(); + + var closeDialog = function() { + $('#kundenkarte-error-dialog').remove(); + $(document).off('keydown.errorDialog'); + }; + + $('#error-ok, #kundenkarte-error-dialog .kundenkarte-modal-close').on('click', closeDialog); + $(document).on('keydown.errorDialog', function(e) { + if (e.key === 'Escape' || e.key === 'Enter') closeDialog(); + }); + }; + + // Global success/info notification (non-blocking) + KundenKarte.showNotification = function(message, type) { + type = type || 'success'; + var bgColor = type === 'success' ? '#27ae60' : (type === 'warning' ? '#f39c12' : (type === 'error' ? '#e74c3c' : '#3498db')); + var icon = type === 'success' ? 'fa-check' : (type === 'warning' ? 'fa-exclamation' : (type === 'error' ? 'fa-times' : 'fa-info')); + + var $note = $('
' + escapeHtml(message) + '
'); + $('body').append($note); + + setTimeout(function() { + $note.fadeOut(300, function() { $(this).remove(); }); + }, 3000); + }; + + // Get base URL for AJAX calls + var baseUrl = (typeof DOL_URL_ROOT !== 'undefined') ? DOL_URL_ROOT : ''; + if (!baseUrl) { + // Try to detect from script src + var scripts = document.getElementsByTagName('script'); + for (var i = 0; i < scripts.length; i++) { + var src = scripts[i].src; + if (src && src.indexOf('/kundenkarte/js/kundenkarte.js') > -1) { + baseUrl = src.replace('/custom/kundenkarte/js/kundenkarte.js', '').replace(/\?.*$/, ''); + break; + } + } + } + + /** + * Tree Component + */ + KundenKarte.Tree = { + tooltipTimeout: null, + hideTimeout: null, + currentTooltip: null, + currentItem: null, + draggedNode: null, + isDragging: false, + dropTarget: null, + + init: function() { + this.bindEvents(); + this.initDragDrop(); + this.initCompactMode(); + }, + + bindEvents: function() { + var self = this; + + // Toggle tree nodes - MUST use stopImmediatePropagation for delegated handlers on same element + $(document).on('click', '.kundenkarte-tree-toggle', function(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + var $toggle = $(this); + var $node = $toggle.closest('.kundenkarte-tree-node'); + var $children = $node.children('.kundenkarte-tree-children'); + + $toggle.toggleClass('collapsed'); + $children.toggleClass('collapsed'); + }); + + // Expand all nodes + $(document).on('click', '#btn-expand-all', function(e) { + e.preventDefault(); + self.expandAll(); + }); + + // Collapse all nodes + $(document).on('click', '#btn-collapse-all', function(e) { + e.preventDefault(); + self.collapseAll(); + }); + + // Compact mode toggle + $(document).on('click', '#btn-compact-mode', function(e) { + e.preventDefault(); + self.toggleCompactMode(); + }); + + // In compact mode, click on item to expand/show details + $(document).on('click', '.kundenkarte-tree.compact-mode .kundenkarte-tree-item', function(e) { + // Don't trigger on action buttons or toggle + if ($(e.target).closest('.kundenkarte-tree-actions, .kundenkarte-tree-toggle, .kundenkarte-tree-files').length) { + return; + } + $(this).toggleClass('expanded'); + }); + + // Hover tooltip on ICON only - show after delay + $(document).on('mouseenter', '.kundenkarte-tooltip-trigger', function(e) { + var $trigger = $(this); + var anlageId = $trigger.data('anlage-id'); + + if (!anlageId) return; + + // Cancel any pending hide + clearTimeout(self.hideTimeout); + self.hideTimeout = null; + + self.currentItem = $trigger; + + self.tooltipTimeout = setTimeout(function() { + self.showTooltip($trigger, anlageId); + }, 300); + }); + + // Hide tooltip when leaving icon + $(document).on('mouseleave', '.kundenkarte-tooltip-trigger', function() { + clearTimeout(self.tooltipTimeout); + self.tooltipTimeout = null; + self.currentItem = null; + + // Hide after short delay (allows moving to tooltip) + self.hideTimeout = setTimeout(function() { + self.hideTooltip(); + }, 100); + }); + + // Images tooltip on hover + $(document).on('mouseenter', '.kundenkarte-images-trigger', function(e) { + var $trigger = $(this); + var anlageId = $trigger.data('anlage-id'); + + if (!anlageId) return; + + clearTimeout(self.hideTimeout); + self.hideTimeout = null; + + self.tooltipTimeout = setTimeout(function() { + self.showImagesPopup($trigger, anlageId); + }, 300); + }); + + $(document).on('mouseleave', '.kundenkarte-images-trigger', function() { + clearTimeout(self.tooltipTimeout); + self.tooltipTimeout = null; + + self.hideTimeout = setTimeout(function() { + self.hideTooltip(); + }, 100); + }); + + // Documents tooltip on hover + $(document).on('mouseenter', '.kundenkarte-docs-trigger', function(e) { + var $trigger = $(this); + var anlageId = $trigger.data('anlage-id'); + + if (!anlageId) return; + + clearTimeout(self.hideTimeout); + self.hideTimeout = null; + + self.tooltipTimeout = setTimeout(function() { + self.showDocsPopup($trigger, anlageId); + }, 300); + }); + + $(document).on('mouseleave', '.kundenkarte-docs-trigger', function() { + clearTimeout(self.tooltipTimeout); + self.tooltipTimeout = null; + + self.hideTimeout = setTimeout(function() { + self.hideTooltip(); + }, 100); + }); + + // File badge tooltip on hover (combined images + documents) + $(document).on('mouseenter', '.kundenkarte-tree-file-badge', function(e) { + var $trigger = $(this); + var anlageId = $trigger.data('anlage-id'); + + if (!anlageId) return; + + clearTimeout(self.hideTimeout); + self.hideTimeout = null; + + self.tooltipTimeout = setTimeout(function() { + self.showFilePreview($trigger, anlageId); + }, 300); + }); + + $(document).on('mouseleave', '.kundenkarte-tree-file-badge', function() { + clearTimeout(self.tooltipTimeout); + self.tooltipTimeout = null; + + self.hideTimeout = setTimeout(function() { + self.hideTooltip(); + }, 100); + }); + + // Keep tooltip visible when hovering over it + $(document).on('mouseenter', '#kundenkarte-tooltip', function() { + clearTimeout(self.hideTimeout); + self.hideTimeout = null; + }); + + // Hide when leaving tooltip + $(document).on('mouseleave', '#kundenkarte-tooltip', function() { + self.hideTooltip(); + }); + + // Select item + $(document).on('click', '.kundenkarte-tree-item', function(e) { + if ($(e.target).closest('.kundenkarte-tree-toggle, .kundenkarte-tree-actions, .kundenkarte-tree-files').length) { + return; + } + + $('.kundenkarte-tree-item').removeClass('selected'); + $(this).addClass('selected'); + + var anlageId = $(this).data('anlage-id'); + if (anlageId) { + $(document).trigger('kundenkarte:element:selected', [anlageId]); + } + }); + }, + + showTooltip: function($item, anlageId) { + var self = this; + + // Get tooltip data from data attribute (faster than AJAX) + var tooltipDataStr = $item.attr('data-tooltip'); + if (!tooltipDataStr) { + console.log('No tooltip data for anlage', anlageId); + return; + } + + var data; + try { + data = JSON.parse(tooltipDataStr); + } catch(e) { + console.error('Failed to parse tooltip JSON:', e, tooltipDataStr); + return; + } + + var html = self.buildTooltipHtml(data); + var $tooltip = $('#kundenkarte-tooltip'); + + if (!$tooltip.length) { + $tooltip = $('
'); + $('body').append($tooltip); + } + + $tooltip.html(html); + + // Position tooltip + var offset = $item.offset(); + var itemWidth = $item.outerWidth(); + var windowWidth = $(window).width(); + var scrollTop = $(window).scrollTop(); + + // First show to calculate width + $tooltip.css({ visibility: 'hidden', display: 'block' }); + var tooltipWidth = $tooltip.outerWidth(); + var tooltipHeight = $tooltip.outerHeight(); + $tooltip.css({ visibility: '', display: '' }); + + var left = offset.left + itemWidth + 10; + if (left + tooltipWidth > windowWidth - 20) { + left = offset.left - tooltipWidth - 10; + } + if (left < 10) { + left = 10; + } + + var top = offset.top; + // Prevent tooltip from going below viewport + if (top + tooltipHeight > scrollTop + $(window).height() - 20) { + top = scrollTop + $(window).height() - tooltipHeight - 20; + } + + $tooltip.css({ + top: top, + left: left + }).addClass('visible').show(); + + self.currentTooltip = $tooltip; + }, + + hideTooltip: function() { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + var $tooltip = $('#kundenkarte-tooltip'); + if ($tooltip.length) { + $tooltip.removeClass('visible').hide(); + } + this.currentTooltip = null; + }, + + showImagesPopup: function($trigger, anlageId) { + var self = this; + + // Load images via AJAX - use absolute path + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/anlage_images.php', + data: { anlage_id: anlageId }, + dataType: 'json', + success: function(response) { + if (!response.images || response.images.length === 0) { + return; + } + + var html = '
'; + html += '
'; + for (var i = 0; i < response.images.length; i++) { + var img = response.images[i]; + html += ''; + html += '' + self.escapeHtml(img.name) + ''; + html += ''; + } + html += '
'; + html += '
'; + + var $tooltip = $('#kundenkarte-tooltip'); + if (!$tooltip.length) { + $tooltip = $('
'); + $('body').append($tooltip); + } + + $tooltip.html(html); + + // Position tooltip + var offset = $trigger.offset(); + var windowWidth = $(window).width(); + var scrollTop = $(window).scrollTop(); + + $tooltip.css({ visibility: 'hidden', display: 'block' }); + var tooltipWidth = $tooltip.outerWidth(); + var tooltipHeight = $tooltip.outerHeight(); + $tooltip.css({ visibility: '', display: '' }); + + var left = offset.left + $trigger.outerWidth() + 10; + if (left + tooltipWidth > windowWidth - 20) { + left = offset.left - tooltipWidth - 10; + } + if (left < 10) left = 10; + + var top = offset.top; + if (top + tooltipHeight > scrollTop + $(window).height() - 20) { + top = scrollTop + $(window).height() - tooltipHeight - 20; + } + + $tooltip.css({ top: top, left: left }).addClass('visible').show(); + self.currentTooltip = $tooltip; + } + }); + }, + + showDocsPopup: function($trigger, anlageId) { + var self = this; + + // Load documents via AJAX + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/anlage_docs.php', + data: { anlage_id: anlageId }, + dataType: 'json', + success: function(response) { + if (!response.docs || response.docs.length === 0) { + return; + } + + // Visual document cards with icons + var html = '
'; + html += '
'; + for (var i = 0; i < response.docs.length; i++) { + var doc = response.docs[i]; + var iconClass = doc.type === 'pdf' ? 'fa-file-pdf-o' : 'fa-file-text-o'; + var iconColor = doc.type === 'pdf' ? '#e74c3c' : '#f39c12'; + html += ''; + html += '
'; + html += ''; + html += '
'; + html += '
' + self.escapeHtml(doc.name) + '
'; + html += '
'; + } + html += '
'; + html += '
'; + + var $tooltip = $('#kundenkarte-tooltip'); + if (!$tooltip.length) { + $tooltip = $('
'); + $('body').append($tooltip); + } + + $tooltip.html(html); + + // Position tooltip + var offset = $trigger.offset(); + var windowWidth = $(window).width(); + var scrollTop = $(window).scrollTop(); + + $tooltip.css({ visibility: 'hidden', display: 'block' }); + var tooltipWidth = $tooltip.outerWidth(); + var tooltipHeight = $tooltip.outerHeight(); + $tooltip.css({ visibility: '', display: '' }); + + var left = offset.left + $trigger.outerWidth() + 10; + if (left + tooltipWidth > windowWidth - 20) { + left = offset.left - tooltipWidth - 10; + } + if (left < 10) left = 10; + + var top = offset.top; + if (top + tooltipHeight > scrollTop + $(window).height() - 20) { + top = scrollTop + $(window).height() - tooltipHeight - 20; + } + + $tooltip.css({ top: top, left: left }).addClass('visible').show(); + self.currentTooltip = $tooltip; + } + }); + }, + + showFilePreview: function($trigger, anlageId) { + var self = this; + + // Load all files via AJAX + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/file_preview.php', + data: { anlage_id: anlageId }, + dataType: 'json', + success: function(response) { + if ((!response.images || response.images.length === 0) && + (!response.documents || response.documents.length === 0)) { + return; + } + + var html = '
'; + + // Images section with thumbnails + if (response.images && response.images.length > 0) { + html += '
'; + html += '
Bilder (' + response.images.length + ')
'; + html += '
'; + for (var i = 0; i < response.images.length && i < 6; i++) { + var img = response.images[i]; + var pinnedClass = img.is_pinned ? ' is-pinned' : ''; + var coverClass = img.is_cover ? ' is-cover' : ''; + html += ''; + html += '' + self.escapeHtml(img.name) + ''; + if (img.is_pinned) { + html += ''; + } + html += ''; + } + if (response.images.length > 6) { + html += '+' + (response.images.length - 6) + ''; + } + html += '
'; + html += '
'; + } + + // Documents section with icons + if (response.documents && response.documents.length > 0) { + html += '
'; + html += '
Dokumente (' + response.documents.length + ')
'; + html += '
'; + for (var j = 0; j < response.documents.length && j < 5; j++) { + var doc = response.documents[j]; + var docPinnedClass = doc.is_pinned ? ' is-pinned' : ''; + html += ''; + html += ''; + html += '' + self.escapeHtml(doc.name) + ''; + if (doc.is_pinned) { + html += ''; + } + html += ''; + } + if (response.documents.length > 5) { + html += '
+' + (response.documents.length - 5) + ' weitere
'; + } + html += '
'; + html += '
'; + } + + html += '
'; + + var $tooltip = $('#kundenkarte-tooltip'); + if (!$tooltip.length) { + $tooltip = $('
'); + $('body').append($tooltip); + } + + $tooltip.html(html); + + // Position tooltip + var offset = $trigger.offset(); + var windowWidth = $(window).width(); + var scrollTop = $(window).scrollTop(); + + $tooltip.css({ visibility: 'hidden', display: 'block' }); + var tooltipWidth = $tooltip.outerWidth(); + var tooltipHeight = $tooltip.outerHeight(); + $tooltip.css({ visibility: '', display: '' }); + + var left = offset.left + $trigger.outerWidth() + 10; + if (left + tooltipWidth > windowWidth - 20) { + left = offset.left - tooltipWidth - 10; + } + if (left < 10) left = 10; + + var top = offset.top; + if (top + tooltipHeight > scrollTop + $(window).height() - 20) { + top = scrollTop + $(window).height() - tooltipHeight - 20; + } + + $tooltip.css({ top: top, left: left }).addClass('visible').show(); + self.currentTooltip = $tooltip; + } + }); + }, + + buildTooltipHtml: function(data) { + var html = '
'; + html += ''; + html += '
'; + html += '
' + this.escapeHtml(data.label || '') + '
'; + html += '
' + this.escapeHtml(data.type || data.type_label || '') + '
'; + html += '
'; + + html += '
'; + + // Dynamic fields only (from PHP data-tooltip attribute) + if (data.fields) { + for (var key in data.fields) { + if (data.fields.hasOwnProperty(key)) { + var field = data.fields[key]; + // Handle header fields as section titles (must span both grid columns) + if (field.type === 'header') { + html += '' + this.escapeHtml(field.label) + ''; + } else if (field.value) { + html += '' + this.escapeHtml(field.label) + ':'; + html += '' + this.escapeHtml(field.value) + ''; + } + } + } + } + + html += '
'; + + // Notes (note_html is already sanitized and formatted with
by PHP) + if (data.note_html) { + html += '
'; + html += '
' + data.note_html; + html += '
'; + } + + // Images (from AJAX) + if (data.images && data.images.length > 0) { + html += '
'; + for (var i = 0; i < Math.min(data.images.length, 4); i++) { + html += ''; + } + html += '
'; + } + + return html; + }, + + escapeHtml: function(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + escapeHtmlPreservingBreaks: function(text) { + if (!text) return ''; + // Convert
to newlines first (for old data with
tags) + text = text.replace(//gi, '\n'); + return this.escapeHtml(text); + }, + + refresh: function(socId, systemId) { + var $container = $('.kundenkarte-tree[data-system="' + systemId + '"]'); + if (!$container.length) return; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/anlage_tree.php', + data: { socid: socId, system: systemId }, + success: function(html) { + $container.html(html); + } + }); + }, + + // Drag & Drop Sortierung initialisieren + initDragDrop: function() { + var self = this; + this.draggedNode = null; + + $(document).on('mousedown', '.kundenkarte-tree-item', function(e) { + // Nicht bei Klick auf Links, Buttons oder Toggle + if ($(e.target).closest('a, button, .kundenkarte-tree-toggle').length) return; + + var $item = $(this); + var $node = $item.closest('.kundenkarte-tree-node'); + + // Nur wenn Schreibberechtigung (Actions vorhanden) + if (!$item.find('.kundenkarte-tree-actions').length) return; + + self.draggedNode = $node; + self.dragStartY = e.pageY; + self.isDragging = false; + + $(document).on('mousemove.treeDrag', function(e) { + // Erst ab 5px Bewegung als Drag werten + if (!self.isDragging && Math.abs(e.pageY - self.dragStartY) > 5) { + self.isDragging = true; + self.draggedNode.addClass('kundenkarte-dragging'); + $('body').addClass('kundenkarte-drag-active'); + } + + if (self.isDragging) { + self.handleDragOver(e); + } + }); + + $(document).on('mouseup.treeDrag', function(e) { + $(document).off('mousemove.treeDrag mouseup.treeDrag'); + + if (self.isDragging) { + self.handleDrop(); + } + + self.draggedNode.removeClass('kundenkarte-dragging'); + $('body').removeClass('kundenkarte-drag-active'); + $('.kundenkarte-drag-above, .kundenkarte-drag-below').removeClass('kundenkarte-drag-above kundenkarte-drag-below'); + self.draggedNode = null; + self.isDragging = false; + self.dropTarget = null; + }); + }); + }, + + handleDragOver: function(e) { + var self = this; + // Alle Markierungen entfernen + $('.kundenkarte-drag-above, .kundenkarte-drag-below').removeClass('kundenkarte-drag-above kundenkarte-drag-below'); + + // Element unter dem Mauszeiger finden + var $target = $(e.target).closest('.kundenkarte-tree-node'); + if (!$target.length || $target.is(self.draggedNode)) return; + + // Nur Geschwister erlauben (gleicher Parent-Container) + var $dragParent = self.draggedNode.parent(); + var $targetParent = $target.parent(); + if (!$dragParent.is($targetParent)) return; + + // Position bestimmen: obere oder untere Hälfte des Ziels + var targetRect = $target.children('.kundenkarte-tree-item')[0].getBoundingClientRect(); + var midY = targetRect.top + targetRect.height / 2; + + if (e.clientY < midY) { + $target.addClass('kundenkarte-drag-above'); + self.dropTarget = { node: $target, position: 'before' }; + } else { + $target.addClass('kundenkarte-drag-below'); + self.dropTarget = { node: $target, position: 'after' }; + } + }, + + handleDrop: function() { + var self = this; + if (!self.dropTarget) return; + + var $target = self.dropTarget.node; + var position = self.dropTarget.position; + + // DOM-Reihenfolge aktualisieren + if (position === 'before') { + self.draggedNode.insertBefore($target); + } else { + self.draggedNode.insertAfter($target); + } + + // Neue Reihenfolge der Geschwister sammeln + var $parent = self.draggedNode.parent(); + var ids = []; + $parent.children('.kundenkarte-tree-node').each(function() { + var id = $(this).children('.kundenkarte-tree-item').data('anlage-id'); + if (id) ids.push(id); + }); + + // Per AJAX speichern + var baseUrl = $('meta[name="dolibarr-baseurl"]').attr('content') || ''; + $.ajax({ + type: 'POST', + url: baseUrl + '/custom/kundenkarte/ajax/anlage.php', + data: { + action: 'reorder', + token: $('input[name="token"]').val() || '', + 'ids[]': ids + }, + dataType: 'json' + }); + }, + + expandAll: function() { + $('.kundenkarte-tree-toggle').removeClass('collapsed'); + $('.kundenkarte-tree-children').removeClass('collapsed'); + }, + + collapseAll: function() { + $('.kundenkarte-tree-toggle').addClass('collapsed'); + $('.kundenkarte-tree-children').addClass('collapsed'); + }, + + toggleCompactMode: function() { + var $tree = $('.kundenkarte-tree'); + var $btn = $('#btn-compact-mode'); + + $tree.toggleClass('compact-mode'); + $btn.toggleClass('active'); + + if ($tree.hasClass('compact-mode')) { + $btn.find('span').text('Normal'); + $btn.find('i').removeClass('fa-compress').addClass('fa-expand'); + // Remove any expanded items + $('.kundenkarte-tree-item.expanded').removeClass('expanded'); + // Store preference + localStorage.setItem('kundenkarte_compact_mode', '1'); + } else { + $btn.find('span').text('Kompakt'); + $btn.find('i').removeClass('fa-expand').addClass('fa-compress'); + localStorage.removeItem('kundenkarte_compact_mode'); + } + }, + + initCompactMode: function() { + // Check localStorage for saved preference + if (localStorage.getItem('kundenkarte_compact_mode') === '1') { + this.toggleCompactMode(); + } + + // Auto-enable on mobile + if (window.innerWidth <= 768 && !localStorage.getItem('kundenkarte_compact_mode_manual')) { + if (!$('.kundenkarte-tree').hasClass('compact-mode')) { + this.toggleCompactMode(); + } + } + } + }; + + /** + * Favorite Products Component + */ + KundenKarte.Favorites = { + init: function() { + this.bindEvents(); + }, + + bindEvents: function() { + // Select all checkbox + $(document).on('change', '#kundenkarte-select-all', function() { + var checked = $(this).prop('checked'); + $('.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]').prop('checked', checked); + KundenKarte.Favorites.updateGenerateButton(); + }); + + // Individual checkbox + $(document).on('change', '.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]', function() { + KundenKarte.Favorites.updateGenerateButton(); + }); + + // Save button click + $(document).on('click', '.kundenkarte-qty-save', function(e) { + e.preventDefault(); + var $btn = $(this); + var favId = $btn.data('fav-id'); + var $input = $('input.kundenkarte-favorites-qty[data-fav-id="' + favId + '"]'); + var qtyStr = $input.val().replace(',', '.'); + var qty = parseFloat(qtyStr); + + if (!isNaN(qty) && qty > 0) { + // Limit to 2 decimal places + qty = Math.round(qty * 100) / 100; + + // Format nicely + var display = (qty % 1 === 0) ? qty.toString() : qty.toFixed(2).replace(/\.?0+$/, ''); + $input.val(display); + + // Visual feedback + $btn.prop('disabled', true); + $btn.find('i').removeClass('fa-save').addClass('fa-spinner fa-spin'); + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/favorite_update.php', + method: 'POST', + data: { id: favId, qty: qty, token: $('input[name="token"]').val() }, + success: function() { + $btn.find('i').removeClass('fa-spinner fa-spin').addClass('fa-check').css('color', '#0a0'); + setTimeout(function() { + $btn.find('i').removeClass('fa-check').addClass('fa-save').css('color', ''); + $btn.prop('disabled', false); + }, 1500); + }, + error: function() { + $btn.find('i').removeClass('fa-spinner fa-spin').addClass('fa-exclamation-triangle').css('color', '#c00'); + setTimeout(function() { + $btn.find('i').removeClass('fa-exclamation-triangle').addClass('fa-save').css('color', ''); + $btn.prop('disabled', false); + }, 2000); + } + }); + } + }); + }, + + updateGenerateButton: function() { + var count = $('.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]:checked').length; + var $btn = $('#btn-generate-order'); + + if (count > 0) { + $btn.prop('disabled', false).text($btn.data('text').replace('%d', count)); + } else { + $btn.prop('disabled', true).text($btn.data('text-none')); + } + } + }; + + /** + * System Tabs Component + */ + KundenKarte.SystemTabs = { + init: function() { + this.bindEvents(); + }, + + bindEvents: function() { + $(document).on('click', '.kundenkarte-system-tab:not(.active):not(.kundenkarte-system-tab-add)', function() { + var systemId = $(this).data('system'); + KundenKarte.SystemTabs.switchTo(systemId); + }); + }, + + switchTo: function(systemId) { + $('.kundenkarte-system-tab').removeClass('active'); + $('.kundenkarte-system-tab[data-system="' + systemId + '"]').addClass('active'); + + $('.kundenkarte-system-content').hide(); + $('.kundenkarte-system-content[data-system="' + systemId + '"]').show(); + + // Update URL without reload + var url = new URL(window.location.href); + url.searchParams.set('system', systemId); + window.history.replaceState({}, '', url); + } + }; + + /** + * Icon Picker Component with Custom Icon Upload + */ + KundenKarte.IconPicker = { + // Common FontAwesome icons for installations/technical use + icons: [ + // Electrical + 'fa-bolt', 'fa-plug', 'fa-power-off', 'fa-charging-station', 'fa-battery-full', 'fa-battery-half', + 'fa-car-battery', 'fa-solar-panel', 'fa-sun', 'fa-lightbulb', 'fa-toggle-on', 'fa-toggle-off', + // Network/Internet + 'fa-wifi', 'fa-network-wired', 'fa-server', 'fa-database', 'fa-hdd', 'fa-ethernet', + 'fa-broadcast-tower', 'fa-satellite-dish', 'fa-satellite', 'fa-signal', 'fa-rss', + // TV/Media + 'fa-tv', 'fa-play-circle', 'fa-video', 'fa-film', 'fa-podcast', 'fa-music', + // Temperature/Climate + 'fa-thermometer-half', 'fa-temperature-high', 'fa-temperature-low', 'fa-fire', 'fa-fire-alt', + 'fa-snowflake', 'fa-wind', 'fa-fan', 'fa-air-freshener', + // Building/Structure + 'fa-home', 'fa-building', 'fa-warehouse', 'fa-door-open', 'fa-door-closed', 'fa-archway', + // Devices/Hardware + 'fa-microchip', 'fa-memory', 'fa-sim-card', 'fa-sd-card', 'fa-usb', 'fa-desktop', 'fa-laptop', + 'fa-mobile-alt', 'fa-tablet-alt', 'fa-keyboard', 'fa-print', 'fa-fax', + // Security + 'fa-shield-alt', 'fa-lock', 'fa-unlock', 'fa-key', 'fa-fingerprint', 'fa-eye', 'fa-video', + 'fa-bell', 'fa-exclamation-triangle', 'fa-user-shield', + // Objects + 'fa-cube', 'fa-cubes', 'fa-box', 'fa-boxes', 'fa-archive', 'fa-toolbox', 'fa-tools', 'fa-wrench', + 'fa-cog', 'fa-cogs', 'fa-sliders-h', + // Layout + 'fa-th', 'fa-th-large', 'fa-th-list', 'fa-grip-horizontal', 'fa-grip-vertical', 'fa-bars', + 'fa-stream', 'fa-layer-group', 'fa-project-diagram', 'fa-share-alt', 'fa-sitemap', + // Arrows/Direction + 'fa-exchange-alt', 'fa-arrows-alt', 'fa-expand', 'fa-compress', 'fa-random', + // Misc + 'fa-tachometer-alt', 'fa-chart-line', 'fa-chart-bar', 'fa-chart-pie', 'fa-chart-area', + 'fa-clock', 'fa-calendar', 'fa-tag', 'fa-tags', 'fa-bookmark', 'fa-star', 'fa-heart', + 'fa-check', 'fa-times', 'fa-plus', 'fa-minus', 'fa-info-circle', 'fa-question-circle', + 'fa-dot-circle', 'fa-circle', 'fa-square', 'fa-adjust' + ], + + customIcons: [], + currentInput: null, + currentTab: 'fontawesome', + + init: function() { + this.createModal(); + this.bindEvents(); + }, + + createModal: function() { + if ($('#kundenkarte-icon-picker-modal').length) return; + + var self = this; + var html = '
'; + html += '
'; + html += '
'; + html += '

Icon auswählen

'; + html += '×'; + html += '
'; + html += '
'; + + // Tabs + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Font Awesome Tab Content + html += '
'; + html += ''; + html += '
'; + for (var i = 0; i < this.icons.length; i++) { + html += '
'; + html += ''; + html += '
'; + } + html += '
'; + html += '
'; + + // Custom Icons Tab Content + html += ''; + + html += '
'; + html += '
'; + html += '
'; + + $('body').append(html); + }, + + bindEvents: function() { + var self = this; + + // Open picker + $(document).on('click', '.kundenkarte-icon-picker-btn', function(e) { + e.preventDefault(); + var inputName = $(this).data('input'); + self.currentInput = $('input[name="' + inputName + '"]'); + self.open(); + }); + + // Close modal + $(document).on('click', '.kundenkarte-modal-close, #kundenkarte-icon-picker-modal', function(e) { + if (e.target === this || $(e.target).hasClass('kundenkarte-modal-close')) { + self.close(); + } + }); + + // Tab switching + $(document).on('click', '.kundenkarte-icon-tab', function() { + var tab = $(this).data('tab'); + self.switchTab(tab); + }); + + // Select FA icon + $(document).on('click', '.kundenkarte-icon-item[data-type="fa"]', function() { + var icon = $(this).data('icon'); + self.selectIcon(icon, 'fa'); + }); + + // Select custom icon + $(document).on('click', '.kundenkarte-icon-item[data-type="custom"]', function() { + var iconUrl = $(this).data('icon'); + self.selectIcon(iconUrl, 'custom'); + }); + + // Search filter (FA only) + $(document).on('input', '#kundenkarte-icon-search', function() { + var search = $(this).val().toLowerCase(); + $('#kundenkarte-fa-icons .kundenkarte-icon-item').each(function() { + var icon = $(this).data('icon').toLowerCase(); + $(this).toggle(icon.indexOf(search) > -1); + }); + }); + + // Upload button click + $(document).on('click', '#kundenkarte-icon-upload-btn', function() { + $('#kundenkarte-icon-upload').click(); + }); + + // File selected + $(document).on('change', '#kundenkarte-icon-upload', function() { + var file = this.files[0]; + if (file) { + self.uploadIcon(file); + } + }); + + // Delete custom icon + $(document).on('click', '.kundenkarte-icon-delete', function(e) { + e.stopPropagation(); + var filename = $(this).data('filename'); + self.showDeleteConfirm(filename); + }); + + // ESC key to close + $(document).on('keydown', function(e) { + if (e.key === 'Escape') { + self.close(); + } + }); + }, + + switchTab: function(tab) { + this.currentTab = tab; + $('.kundenkarte-icon-tab').removeClass('active'); + $('.kundenkarte-icon-tab[data-tab="' + tab + '"]').addClass('active'); + $('.kundenkarte-icon-tab-content').hide(); + $('.kundenkarte-icon-tab-content[data-tab="' + tab + '"]').show(); + + if (tab === 'custom') { + this.loadCustomIcons(); + } + }, + + loadCustomIcons: function() { + var self = this; + var $grid = $('#kundenkarte-custom-icons'); + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php', + data: { action: 'list' }, + dataType: 'json', + success: function(response) { + $grid.empty(); + + if (response.icons && response.icons.length > 0) { + self.customIcons = response.icons; + for (var i = 0; i < response.icons.length; i++) { + var icon = response.icons[i]; + var html = '
'; + html += '' + icon.name + ''; + html += ''; + html += '
'; + $grid.append(html); + } + } else { + $grid.html('

Noch keine eigenen Icons hochgeladen.

'); + } + }, + error: function() { + $grid.html('

Fehler beim Laden der Icons.

'); + } + }); + }, + + uploadIcon: function(file) { + var self = this; + var formData = new FormData(); + formData.append('action', 'upload'); + formData.append('icon', file); + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php', + method: 'POST', + data: formData, + processData: false, + contentType: false, + dataType: 'json', + success: function(response) { + if (response.success) { + self.loadCustomIcons(); + // Auto-select uploaded icon + setTimeout(function() { + self.selectIcon(response.icon.url, 'custom'); + }, 500); + } else { + KundenKarte.showAlert('Fehler', response.error || 'Unbekannter Fehler'); + } + }, + error: function(xhr) { + var msg = 'Upload fehlgeschlagen'; + try { + var resp = JSON.parse(xhr.responseText); + msg = resp.error || msg; + } catch(e) {} + KundenKarte.showAlert('Fehler', msg); + } + }); + + // Reset input + $('#kundenkarte-icon-upload').val(''); + }, + + deleteIcon: function(filename) { + var self = this; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php', + method: 'POST', + data: { action: 'delete', filename: filename }, + dataType: 'json', + success: function(response) { + if (response.success) { + self.loadCustomIcons(); + } else { + KundenKarte.showAlert('Fehler', response.error || 'Löschen fehlgeschlagen'); + } + } + }); + }, + + showDeleteConfirm: function(filename) { + var self = this; + + // Remove any existing confirm dialog + $('#kundenkarte-delete-confirm').remove(); + + // Create Dolibarr-style confirmation dialog + var html = '
'; + html += '
'; + + // Header (Dolibarr style) + html += '
'; + html += 'Bestätigung'; + html += '
'; + + // Body + html += '
'; + html += '

Möchten Sie dieses Icon wirklich löschen?

'; + html += '

' + this.escapeHtml(filename) + '

'; + html += '
'; + + // Footer with buttons (Dolibarr style) + html += ''; + + html += '
'; + html += '
'; + + $('body').append(html); + + // Bind events + $('#kundenkarte-confirm-yes').on('click', function() { + $('#kundenkarte-delete-confirm').remove(); + self.deleteIcon(filename); + }); + + $('#kundenkarte-confirm-no, #kundenkarte-delete-confirm').on('click', function(e) { + if (e.target === this) { + $('#kundenkarte-delete-confirm').remove(); + } + }); + + // ESC to close + $(document).one('keydown.confirmDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-delete-confirm').remove(); + } + }); + }, + + escapeHtml: function(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + selectIcon: function(icon, type) { + if (this.currentInput) { + // For custom icons, store the URL with a prefix to distinguish + var value = (type === 'custom') ? 'img:' + icon : icon; + this.currentInput.val(value); + + // Update preview + var $wrapper = this.currentInput.closest('.kundenkarte-icon-picker-wrapper'); + var $preview = $wrapper.find('.kundenkarte-icon-preview'); + if ($preview.length) { + if (type === 'custom') { + $preview.html(''); + } else { + $preview.html(''); + } + } + } + this.close(); + }, + + open: function() { + $('#kundenkarte-icon-search').val(''); + $('#kundenkarte-fa-icons .kundenkarte-icon-item').show(); + this.switchTab('fontawesome'); + $('#kundenkarte-icon-picker-modal').addClass('visible'); + }, + + close: function() { + $('#kundenkarte-icon-picker-modal').removeClass('visible'); + this.currentInput = null; + } + }; + + /** + * Dynamic Fields Component + * Loads and renders type-specific fields when creating/editing anlagen + */ + KundenKarte.DynamicFields = { + init: function() { + var self = this; + var $typeSelect = $('select[name="fk_anlage_type"]'); + var $container = $('#dynamic_fields'); + + if (!$typeSelect.length || !$container.length) return; + + // Load fields when type changes + $typeSelect.on('change', function() { + self.loadFields($(this).val()); + }); + + // Load initial fields if type is already selected + if ($typeSelect.val()) { + self.loadFields($typeSelect.val()); + } + }, + + loadFields: function(typeId) { + var self = this; + var $container = $('#dynamic_fields'); + if (!typeId) { + $container.html(''); + return; + } + + // Store current type ID for autocomplete + this.currentTypeId = typeId; + + // Get anlage_id if editing or copying + var anlageId = $('input[name="anlage_id"]').val() || $('input[name="copy_from"]').val() || 0; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/type_fields.php', + data: { type_id: typeId, anlage_id: anlageId }, + dataType: 'json', + success: function(data) { + if (data.fields && data.fields.length > 0) { + var html = ''; + + data.fields.forEach(function(field) { + if (field.type === 'header') { + // Header row spans both columns with styling + html += '' + KundenKarte.DynamicFields.escapeHtml(field.label) + ''; + } else { + html += '' + KundenKarte.DynamicFields.escapeHtml(field.label); + if (field.required) html += ' *'; + html += ''; + html += KundenKarte.DynamicFields.renderField(field); + html += ''; + } + }); + + $container.html(html); + } else { + $container.html(''); + } + } + }); + }, + + currentTypeId: 0, + + renderField: function(field) { + var name = 'field_' + field.code; + var value = field.value || ''; + var required = field.required ? ' required' : ''; + var autocompleteClass = field.autocomplete ? ' kk-autocomplete' : ''; + var autocompleteAttrs = field.autocomplete ? ' data-field-code="' + field.code + '" data-type-id="' + this.currentTypeId + '"' : ''; + + switch (field.type) { + case 'text': + return ''; + + case 'textarea': + return ''; + + case 'number': + var attrs = ''; + if (field.options) { + var opts = field.options.split('|'); + opts.forEach(function(opt) { + var parts = opt.split(':'); + if (parts.length === 2) { + attrs += ' ' + parts[0] + '="' + parts[1] + '"'; + } + }); + } + return ''; + + case 'select': + var html = ''; + return html; + + case 'date': + var inputId = 'date_' + name.replace(/[^a-zA-Z0-9]/g, '_'); + return '' + + ''; + + case 'checkbox': + var checked = (value === '1' || value === 'true' || value === 'yes') ? ' checked' : ''; + return ''; + + default: + return ''; + } + }, + + escapeHtml: function(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + }; + + /** + * Equipment Component + * Manages Hutschienen (DIN rail) components with SVG visualization + */ + KundenKarte.Equipment = { + TE_WIDTH: 50, // Width of one TE in pixels (wider for more space) + BLOCK_HEIGHT: 110, // Height of equipment block in pixels + currentCarrierId: null, + currentAnlageId: null, + currentSystemId: null, + draggedEquipment: null, + isSaving: false, // Prevent double-clicks + + init: function(anlageId, systemId) { + if (!anlageId) return; + this.currentAnlageId = anlageId; + this.currentSystemId = systemId || 0; + this.bindEvents(); + }, + + bindEvents: function() { + var self = this; + + // Add panel button + $(document).on('click', '.kundenkarte-add-panel', function(e) { + e.preventDefault(); + var anlageId = $(this).data('anlage-id'); + self.showPanelDialog(anlageId); + }); + + // Edit panel + $(document).on('click', '.kundenkarte-panel-edit', function(e) { + e.preventDefault(); + e.stopPropagation(); + var panelId = $(this).closest('.kundenkarte-panel').data('panel-id'); + self.showPanelDialog(self.currentAnlageId, panelId); + }); + + // Delete panel + $(document).on('click', '.kundenkarte-panel-delete', function(e) { + e.preventDefault(); + e.stopPropagation(); + var panelId = $(this).closest('.kundenkarte-panel').data('panel-id'); + self.deletePanel(panelId); + }); + + // Quick-duplicate panel (+ button next to last panel) + $(document).on('click', '.kundenkarte-panel-quickadd', function(e) { + e.preventDefault(); + e.stopPropagation(); + var panelId = $(this).data('panel-id'); + self.duplicatePanel(panelId); + }); + + // Quick-duplicate carrier (+ button below last carrier) + $(document).on('click', '.kundenkarte-carrier-quickadd', function(e) { + e.preventDefault(); + e.stopPropagation(); + var carrierId = $(this).data('carrier-id'); + self.duplicateCarrier(carrierId); + }); + + // Add carrier button + $(document).on('click', '.kundenkarte-add-carrier', function(e) { + e.preventDefault(); + var anlageId = $(this).data('anlage-id'); + var panelId = $(this).data('panel-id') || 0; + self.showCarrierDialog(anlageId, null, panelId); + }); + + // Edit carrier + $(document).on('click', '.kundenkarte-carrier-edit', function(e) { + e.preventDefault(); + e.stopPropagation(); + var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id'); + self.showCarrierDialog(self.currentAnlageId, carrierId); + }); + + // Delete carrier + $(document).on('click', '.kundenkarte-carrier-delete', function(e) { + e.preventDefault(); + e.stopPropagation(); + var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id'); + self.deleteCarrier(carrierId); + }); + + // Add equipment (click on empty slot or + button) + $(document).on('click', '.kundenkarte-carrier-add-equipment, .kundenkarte-slot-empty', function(e) { + e.preventDefault(); + var $carrier = $(this).closest('.kundenkarte-carrier'); + var carrierId = $carrier.data('carrier-id'); + var position = $(this).data('position') || null; + self.showEquipmentDialog(carrierId, null, position); + }); + + // Edit equipment (click on block) + $(document).on('click', '.kundenkarte-equipment-block', function(e) { + e.preventDefault(); + e.stopPropagation(); + var equipmentId = $(this).data('equipment-id'); + self.showEquipmentDialog(null, equipmentId); + }); + + // Quick-add slot (duplicate last equipment into next free position) + $(document).on('click', '.kundenkarte-slot-quickadd', function(e) { + e.preventDefault(); + e.stopPropagation(); + var equipmentId = $(this).data('equipment-id'); + self.duplicateEquipment(equipmentId); + }); + + // Delete equipment + $(document).on('click', '.kundenkarte-equipment-delete', function(e) { + e.preventDefault(); + e.stopPropagation(); + var equipmentId = $(this).closest('.kundenkarte-equipment-block').data('equipment-id'); + self.deleteEquipment(equipmentId); + }); + + // Add output connection + $(document).on('click', '.kundenkarte-add-output-btn', function(e) { + e.preventDefault(); + var carrierId = $(this).data('carrier-id'); + self.showOutputDialog(carrierId); + }); + + // Add rail connection + $(document).on('click', '.kundenkarte-add-rail-btn', function(e) { + e.preventDefault(); + var carrierId = $(this).data('carrier-id'); + self.showRailDialog(carrierId); + }); + + // Click on rail to edit + $(document).on('click', '.kundenkarte-rail', function(e) { + e.preventDefault(); + e.stopPropagation(); + var connectionId = $(this).data('connection-id'); + // Find carrier ID from connections container or carrier element + var carrierId = $(this).closest('.kundenkarte-connections-container').data('carrier-id') || + $(this).closest('.kundenkarte-carrier').data('carrier-id'); + self.showEditRailDialog(connectionId, carrierId); + }); + + // Click on output to edit + $(document).on('click', '.kundenkarte-output', function(e) { + e.preventDefault(); + e.stopPropagation(); + var connectionId = $(this).data('connection-id'); + // Find carrier ID from connections container or carrier element + var carrierId = $(this).closest('.kundenkarte-connections-container').data('carrier-id') || + $(this).closest('.kundenkarte-carrier').data('carrier-id'); + self.showEditOutputDialog(connectionId, carrierId); + }); + + // Click on connection to delete (generic connections) + $(document).on('click', '.kundenkarte-connection', function(e) { + e.preventDefault(); + var connectionId = $(this).data('connection-id'); + self.deleteConnection(connectionId); + }); + + // Drag & Drop events + $(document).on('dragstart', '.kundenkarte-equipment-block', function(e) { + self.draggedEquipment = $(this); + $(this).addClass('dragging'); + e.originalEvent.dataTransfer.effectAllowed = 'move'; + e.originalEvent.dataTransfer.setData('text/plain', $(this).data('equipment-id')); + }); + + $(document).on('dragend', '.kundenkarte-equipment-block', function() { + $(this).removeClass('dragging'); + self.draggedEquipment = null; + $('.kundenkarte-slot-drop-target').removeClass('kundenkarte-slot-drop-target'); + }); + + $(document).on('dragover', '.kundenkarte-slot-empty, .kundenkarte-carrier-slots', function(e) { + e.preventDefault(); + e.originalEvent.dataTransfer.dropEffect = 'move'; + }); + + $(document).on('dragenter', '.kundenkarte-slot-empty', function(e) { + e.preventDefault(); + $(this).addClass('kundenkarte-slot-drop-target'); + }); + + $(document).on('dragleave', '.kundenkarte-slot-empty', function() { + $(this).removeClass('kundenkarte-slot-drop-target'); + }); + + $(document).on('drop', '.kundenkarte-slot-empty', function(e) { + e.preventDefault(); + $(this).removeClass('kundenkarte-slot-drop-target'); + + if (self.draggedEquipment) { + var equipmentId = self.draggedEquipment.data('equipment-id'); + var newPosition = $(this).data('position'); + self.moveEquipment(equipmentId, newPosition); + } + }); + + // Equipment type change in dialog + $(document).on('change', '#equipment_type_id', function() { + self.loadEquipmentTypeFields($(this).val()); + }); + + // Hover tooltip for equipment + $(document).on('mouseenter', '.kundenkarte-equipment-block', function() { + var $block = $(this); + var tooltipData = $block.attr('data-tooltip'); + if (tooltipData) { + self.showEquipmentTooltip($block, JSON.parse(tooltipData)); + } + }); + + $(document).on('mouseleave', '.kundenkarte-equipment-block', function() { + self.hideEquipmentTooltip(); + }); + }, + + loadCarriers: function(anlageId) { + var self = this; + var $container = $('.kundenkarte-equipment-container[data-anlage-id="' + anlageId + '"]'); + + if (!$container.length) return; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php', + data: { action: 'list', anlage_id: anlageId }, + dataType: 'json', + success: function(response) { + if (response.success) { + self.renderCarriers($container, response.carriers); + } + } + }); + }, + + renderCarriers: function($container, carriers) { + var self = this; + var html = ''; + + if (carriers.length === 0) { + html = '
Keine Hutschienen vorhanden. Klicken Sie auf "Hutschiene hinzufügen".
'; + } else { + carriers.forEach(function(carrier) { + html += self.renderCarrier(carrier); + }); + } + + $container.find('.kundenkarte-carriers-list').html(html); + }, + + renderCarrier: function(carrier) { + var self = this; + var totalWidth = carrier.total_te * this.TE_WIDTH; + + var html = '
'; + + // Header + html += '
'; + html += '' + this.escapeHtml(carrier.label || 'Hutschiene') + ''; + html += '' + carrier.used_te + '/' + carrier.total_te + ' TE belegt'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + + // SVG Rail + html += '
'; + html += ''; + + // Background grid (TE markers) + for (var i = 0; i <= carrier.total_te; i++) { + var x = i * this.TE_WIDTH; + html += ''; + } + + // Render equipment blocks + if (carrier.equipment) { + carrier.equipment.forEach(function(eq) { + html += self.renderEquipmentBlock(eq); + }); + } + + html += ''; + + // Clickable slots overlay (for adding equipment) + html += '
'; + var occupiedSlots = this.getOccupiedSlots(carrier.equipment); + for (var pos = 1; pos <= carrier.total_te; pos++) { + if (!occupiedSlots[pos]) { + var slotLeft = (pos - 1) * this.TE_WIDTH; + html += '
'; + } + } + html += '
'; + + html += '
'; // svg-container + html += '
'; // carrier + + return html; + }, + + renderEquipmentBlock: function(equipment) { + var x = (equipment.position_te - 1) * this.TE_WIDTH; + var width = equipment.width_te * this.TE_WIDTH; + var color = equipment.block_color || equipment.type_color || '#3498db'; + var label = equipment.block_label || equipment.type_label_short || ''; + + // Build tooltip data + var tooltipData = { + label: equipment.label, + type: equipment.type_label, + fields: equipment.field_values || {} + }; + + var html = ''; + + // Label text (centered) + var fontSize = width < 40 ? 11 : 14; + html += ''; + html += this.escapeHtml(label); + html += ''; + + // Duplicate button (+) at the right edge + html += ''; + + return html; + }, + + getOccupiedSlots: function(equipment) { + var slots = {}; + if (equipment) { + equipment.forEach(function(eq) { + for (var i = 0; i < eq.width_te; i++) { + slots[eq.position_te + i] = true; + } + }); + } + return slots; + }, + + // Panel dialog functions + showPanelDialog: function(anlageId, panelId) { + var self = this; + + if ($('#kundenkarte-panel-dialog').length) return; + + var isEdit = !!panelId; + var title = isEdit ? 'Feld bearbeiten' : 'Feld hinzufügen'; + + var panelData = { label: '' }; + + var showDialog = function(data) { + var html = '
'; + html += '
'; + html += '

' + title + '

'; + html += '×
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
Bezeichnung
'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-panel-dialog').addClass('visible'); + + $('#panel-save').on('click', function() { + var label = $('input[name="panel_label"]').val(); + self.savePanel(anlageId, panelId, label); + }); + + $('#panel-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-panel-dialog').remove(); + }); + + $(document).on('keydown.panelDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-panel-dialog').remove(); + $(document).off('keydown.panelDialog'); + } + }); + }; + + if (isEdit) { + var $panel = $('.kundenkarte-panel[data-panel-id="' + panelId + '"]'); + panelData.label = $panel.find('.kundenkarte-panel-label').text(); + showDialog(panelData); + } else { + showDialog(panelData); + } + }, + + savePanel: function(anlageId, panelId, label) { + var self = this; + + if (self.isSaving) return; + self.isSaving = true; + $('#panel-save').prop('disabled', true); + + var action = panelId ? 'update' : 'create'; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php', + method: 'POST', + data: { + action: action, + anlage_id: anlageId, + panel_id: panelId, + label: label, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + self.isSaving = false; + if (response.success) { + $('#kundenkarte-panel-dialog').remove(); + location.reload(); + } else { + $('#panel-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + self.isSaving = false; + $('#panel-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + deletePanel: function(panelId) { + var self = this; + self.showConfirmDialog('Feld löschen', 'Möchten Sie dieses Feld wirklich löschen? Alle Hutschienen und Equipment werden ebenfalls gelöscht.', function() { + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php', + method: 'POST', + data: { + action: 'delete', + panel_id: panelId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + self.showAlertDialog('Fehler', response.error); + } + }, + error: function() { + self.showAlertDialog('Fehler', 'Netzwerkfehler'); + } + }); + }); + }, + + duplicatePanel: function(panelId) { + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php', + method: 'POST', + data: { + action: 'duplicate', + panel_id: panelId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + duplicateCarrier: function(carrierId) { + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php', + method: 'POST', + data: { + action: 'duplicate', + carrier_id: carrierId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + showCarrierDialog: function(anlageId, carrierId, panelId) { + var self = this; + + // Prevent opening multiple dialogs + if ($('#kundenkarte-carrier-dialog').length) return; + + var isEdit = !!carrierId; + var title = isEdit ? 'Hutschiene bearbeiten' : 'Hutschiene hinzufügen'; + panelId = panelId || 0; + + // Load carrier data if editing + var carrierData = { label: '', total_te: 12, fk_panel: panelId }; + + var showDialog = function(data, panels) { + var html = '
'; + html += '
'; + html += '

' + title + '

'; + html += '×
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + // Panel dropdown (only show if panels exist) + if (panels && panels.length > 0) { + html += ''; + html += ''; + } + + html += '
Bezeichnung
Kapazität (TE)
Feld
'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-carrier-dialog').addClass('visible'); + + // Save button + $('#carrier-save').on('click', function() { + var label = $('input[name="carrier_label"]').val(); + var totalTe = parseInt($('input[name="carrier_total_te"]').val()) || 12; + var selectedPanelId = $('select[name="carrier_panel_id"]').val() || 0; + + self.saveCarrier(anlageId, carrierId, label, totalTe, selectedPanelId); + }); + + // Close + $('#carrier-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-carrier-dialog').remove(); + }); + + $(document).on('keydown.carrierDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-carrier-dialog').remove(); + $(document).off('keydown.carrierDialog'); + } + }); + }; + + // Load panels for the anlage + var loadPanelsAndShow = function() { + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php', + data: { action: 'list', anlage_id: self.currentAnlageId }, + dataType: 'json', + success: function(response) { + var panels = response.success ? response.panels : []; + showDialog(carrierData, panels); + }, + error: function() { + showDialog(carrierData, []); + } + }); + }; + + if (isEdit) { + // Fetch existing carrier data via AJAX + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php', + data: { action: 'get', carrier_id: carrierId }, + dataType: 'json', + success: function(response) { + if (response.success && response.carrier) { + carrierData.label = response.carrier.label; + carrierData.total_te = response.carrier.total_te; + carrierData.fk_panel = response.carrier.fk_panel || 0; + } + loadPanelsAndShow(); + }, + error: function() { + // Fallback to DOM data + var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]'); + carrierData.label = $carrier.find('.kundenkarte-carrier-label').text(); + var infoText = $carrier.find('.kundenkarte-carrier-info').text(); + var match = infoText.match(/\/(\d+)/); + if (match) carrierData.total_te = parseInt(match[1]); + loadPanelsAndShow(); + } + }); + } else { + carrierData.fk_panel = panelId; + loadPanelsAndShow(); + } + }, + + saveCarrier: function(anlageId, carrierId, label, totalTe, panelId) { + var self = this; + + // Prevent double-click + if (self.isSaving) return; + self.isSaving = true; + $('#carrier-save').prop('disabled', true); + + var action = carrierId ? 'update' : 'create'; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php', + method: 'POST', + data: { + action: action, + anlage_id: anlageId, + carrier_id: carrierId, + panel_id: panelId || 0, + label: label, + total_te: totalTe, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + self.isSaving = false; + if (response.success) { + $('#kundenkarte-carrier-dialog').remove(); + // Reload page to get fresh PHP-rendered carriers + location.reload(); + } else { + $('#carrier-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + self.isSaving = false; + $('#carrier-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + deleteCarrier: function(carrierId) { + var self = this; + + self.showConfirmDialog('Hutschiene löschen', 'Möchten Sie diese Hutschiene wirklich löschen? Alle Equipment darauf wird ebenfalls gelöscht.', function() { + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php', + method: 'POST', + data: { + action: 'delete', + carrier_id: carrierId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + self.showAlertDialog('Fehler', response.error); + } + } + }); + }); + }, + + showEquipmentDialog: function(carrierId, equipmentId, position) { + var self = this; + + // Prevent opening multiple dialogs + if ($('#kundenkarte-equipment-dialog').length) return; + + var isEdit = !!equipmentId; + var title = isEdit ? 'Equipment bearbeiten' : 'Equipment hinzufügen'; + + // First, load equipment types + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_type_fields.php', + data: { type_id: 0 }, // Get types list + dataType: 'json', + success: function() { + self.renderEquipmentDialog(carrierId, equipmentId, position, title, isEdit); + } + }); + }, + + renderEquipmentDialog: function(carrierId, equipmentId, position, title, isEdit) { + var self = this; + + var html = '
'; + html += '
'; + html += '

' + title + '

'; + html += '×
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Typ *
Bezeichnung
'; + html += ' FI/RCD-Zuordnung
Schutzeinrichtung
Schutzbezeichnung
'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-equipment-dialog').addClass('visible'); + + // Load equipment types into select + self.loadEquipmentTypes(equipmentId); + + // Save button + $('#equipment-save').on('click', function() { + self.saveEquipment(); + }); + + // Delete button + $('#equipment-delete').on('click', function() { + $('#kundenkarte-equipment-dialog').remove(); + self.showConfirmDialog('Equipment löschen', 'Möchten Sie dieses Equipment wirklich löschen?', function() { + self.deleteEquipment(equipmentId); + }); + }); + + // Close + $('#equipment-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-equipment-dialog').remove(); + }); + + $(document).on('keydown.equipmentDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-equipment-dialog').remove(); + $(document).off('keydown.equipmentDialog'); + } + }); + }, + + loadEquipmentTypes: function(equipmentId) { + var self = this; + + // Get equipment types from admin config (stored in page data or via AJAX) + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + data: { action: 'get_types', system_id: self.currentSystemId }, + dataType: 'json', + success: function(response) { + if (response.types) { + var $select = $('#equipment_type_id'); + // Clear existing options except first placeholder + $select.find('option:not(:first)').remove(); + response.types.forEach(function(type) { + $select.append(''); + }); + + // Load protection devices + self.loadProtectionDevices(); + + // If editing, load equipment data + if (equipmentId) { + self.loadEquipmentData(equipmentId); + } + } + } + }); + }, + + loadProtectionDevices: function() { + var self = this; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + data: { action: 'get_protection_devices', anlage_id: self.currentAnlageId }, + dataType: 'json', + success: function(response) { + if (response.success && response.devices) { + var $select = $('#equipment_fk_protection'); + $select.find('option:not(:first)').remove(); + response.devices.forEach(function(device) { + $select.append(''); + }); + } + } + }); + }, + + loadEquipmentData: function(equipmentId) { + var self = this; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + data: { action: 'get', equipment_id: equipmentId }, + dataType: 'json', + success: function(response) { + if (response.equipment) { + var eq = response.equipment; + $('input[name="equipment_carrier_id"]').val(eq.fk_carrier); + $('input[name="equipment_label"]').val(eq.label); + $('input[name="equipment_position"]').val(eq.position_te); + $('#equipment_type_id').val(eq.type_id).trigger('change'); + // Protection fields + if (eq.fk_protection) { + $('#equipment_fk_protection').val(eq.fk_protection); + } + if (eq.protection_label) { + $('input[name="equipment_protection_label"]').val(eq.protection_label); + } + } + } + }); + }, + + loadEquipmentTypeFields: function(typeId) { + var self = this; + var $container = $('#equipment-dynamic-fields'); + var equipmentId = $('input[name="equipment_id"]').val(); + + if (!typeId) { + $container.html(''); + return; + } + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_type_fields.php', + data: { type_id: typeId, equipment_id: equipmentId }, + dataType: 'json', + success: function(response) { + if (response.success && response.fields) { + var html = ''; + response.fields.forEach(function(field) { + html += '' + self.escapeHtml(field.label); + if (field.required) html += ' *'; + html += ''; + html += self.renderEquipmentField(field); + html += ''; + }); + $container.html(html); + } else { + $container.html(''); + } + } + }); + }, + + renderEquipmentField: function(field) { + var name = 'eq_field_' + field.code; + var value = field.value || ''; + var required = field.required ? ' required' : ''; + + switch (field.type) { + case 'select': + var html = ''; + return html; + + case 'number': + return ''; + + default: + return ''; + } + }, + + saveEquipment: function() { + var self = this; + + // Prevent double-click + if (self.isSaving) return; + + var carrierId = $('input[name="equipment_carrier_id"]').val(); + var equipmentId = $('input[name="equipment_id"]').val(); + var typeId = $('#equipment_type_id').val(); + var position = $('input[name="equipment_position"]').val(); + var label = $('input[name="equipment_label"]').val(); + var fkProtection = $('#equipment_fk_protection').val(); + var protectionLabel = $('input[name="equipment_protection_label"]').val(); + + if (!typeId) { + KundenKarte.showAlert('Hinweis', 'Bitte wählen Sie einen Typ.'); + return; + } + + self.isSaving = true; + $('#equipment-save').prop('disabled', true); + + // Collect field values + var fieldValues = {}; + $('#equipment-dynamic-fields input, #equipment-dynamic-fields select').each(function() { + var name = $(this).attr('name'); + if (name && name.startsWith('eq_field_')) { + var code = name.replace('eq_field_', ''); + fieldValues[code] = $(this).val(); + } + }); + + var action = equipmentId ? 'update' : 'create'; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + method: 'POST', + data: { + action: action, + carrier_id: carrierId, + equipment_id: equipmentId, + type_id: typeId, + label: label, + position_te: position, + fk_protection: fkProtection, + protection_label: protectionLabel, + field_values: JSON.stringify(fieldValues), + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + self.isSaving = false; + if (response.success) { + $('#kundenkarte-equipment-dialog').remove(); + // Reload page to get fresh data + location.reload(); + } else { + $('#equipment-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + self.isSaving = false; + $('#equipment-save').prop('disabled', false); + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + duplicateEquipment: function(equipmentId) { + var self = this; + + if (self.isSaving) return; + self.isSaving = true; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + method: 'POST', + data: { + action: 'duplicate', + equipment_id: equipmentId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + self.isSaving = false; + if (response.success) { + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + self.isSaving = false; + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + deleteEquipment: function(equipmentId) { + var self = this; + + // No confirm here - handled by caller + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + method: 'POST', + data: { + action: 'delete', + equipment_id: equipmentId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + self.showAlertDialog('Fehler', response.error); + } + } + }); + }, + + moveEquipment: function(equipmentId, newPosition) { + var self = this; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + method: 'POST', + data: { + action: 'move', + equipment_id: equipmentId, + position_te: newPosition, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + } + }); + }, + + showEquipmentTooltip: function($block, data) { + var self = this; + + var html = '
'; + html += '' + this.escapeHtml(data.type || '') + ''; + if (data.label) { + html += '
' + this.escapeHtml(data.label) + ''; + } + html += '
'; + + if (data.fields && Object.keys(data.fields).length > 0) { + html += '
'; + for (var key in data.fields) { + if (data.fields[key]) { + html += '
' + this.escapeHtml(key) + ': '; + html += '' + this.escapeHtml(data.fields[key]) + '
'; + } + } + html += '
'; + } + + var $tooltip = $('#kundenkarte-equipment-tooltip'); + if (!$tooltip.length) { + $tooltip = $('
'); + $('body').append($tooltip); + } + + $tooltip.html(html); + + // Position near the block + var offset = $block.offset ? $block.offset() : $(this).offset(); + var rect = $block[0].getBoundingClientRect ? $block[0].getBoundingClientRect() : { right: 0, top: 0 }; + + $tooltip.css({ + top: rect.top + window.scrollY + 10, + left: rect.right + window.scrollX + 10 + }).addClass('visible'); + }, + + hideEquipmentTooltip: function() { + $('#kundenkarte-equipment-tooltip').removeClass('visible'); + }, + + // ========================================== + // CONNECTION METHODS (Stromverbindungen) + // ========================================== + + showOutputDialog: function(carrierId) { + var self = this; + + if ($('#kundenkarte-output-dialog').length) return; + + // Get equipment on this carrier for dropdown + var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]'); + var equipmentOptions = []; + + $carrier.find('.kundenkarte-equipment-block').each(function() { + var $block = $(this); + var tooltipData = $block.data('tooltip'); + var id = $block.data('equipment-id'); + var label = tooltipData ? (tooltipData.label || tooltipData.type || 'Equipment ' + id) : 'Equipment ' + id; + equipmentOptions.push({ id: id, label: label }); + }); + + var html = '
'; + html += '
'; + html += '

Abgang hinzufügen

'; + html += '×
'; + html += '
'; + html += '
'; + + // Equipment selection + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Phase selection + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += ''; + html += '
'; + html += '
'; + + // Consumer label + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Cable info + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; // form + html += '
'; // body + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-output-dialog').addClass('visible'); + + // Phase button handlers + $('#kundenkarte-output-dialog .kundenkarte-phase-btn').on('click', function() { + $('#kundenkarte-output-dialog .kundenkarte-phase-btn').removeClass('active'); + $(this).addClass('active'); + $('input[name="output_connection_type"]').val($(this).data('phase')); + }); + + // Save + $('#output-save').on('click', function() { + self.saveOutput(carrierId); + }); + + // Close + $('#output-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-output-dialog').remove(); + }); + }, + + saveOutput: function(carrierId) { + var self = this; + + var data = { + action: 'create_output', + carrier_id: carrierId, + equipment_id: $('select[name="output_equipment_id"]').val(), + connection_type: $('input[name="output_connection_type"]').val(), + output_label: $('input[name="output_consumer_label"]').val(), + medium_type: $('select[name="output_cable_type"]').val(), + medium_spec: $('select[name="output_cable_section"]').val(), + token: $('input[name="token"]').val() + }; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + method: 'POST', + data: data, + dataType: 'json', + success: function(response) { + if (response.success) { + $('#kundenkarte-output-dialog').remove(); + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + showRailDialog: function(carrierId) { + var self = this; + + if ($('#kundenkarte-rail-dialog').length) return; + + // Get carrier info + var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]'); + var totalTE = 12; + var infoText = $carrier.find('.kundenkarte-carrier-info').text(); + var match = infoText.match(/\/(\d+)/); + if (match) totalTE = parseInt(match[1]); + + var html = '
'; + html += '
'; + html += '

Sammelschiene hinzufügen

'; + html += '×
'; + html += '
'; + html += '
'; + + // Quick presets row + html += '
'; + html += ''; + html += '
'; + // Electrical presets + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '|'; + // Network presets + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Connection type (free text) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Position (above/below) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // TE range + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Multi-phase rail options + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Excluded positions (for FI switches etc.) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += 'An diesen Positionen wird die Schiene unterbrochen'; + html += '
'; + html += '
'; + + html += '
'; // form + html += '
'; // body + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-rail-dialog').addClass('visible'); + + // Preset button handlers + $('#kundenkarte-rail-dialog .rail-preset-btn').on('click', function() { + var type = $(this).data('type'); + $('input[name="rail_connection_type"]').val(type); + $('input[name="rail_color"]').val($(this).data('color')); + + // Auto-set rail_phases for electrical presets + var phasesSelect = $('select[name="rail_phases"]'); + if (type === 'L1') phasesSelect.val('L1'); + else if (type === 'L1N') phasesSelect.val('L1N'); + else if (type === '3P') phasesSelect.val('3P'); + else if (type === '3P+N') phasesSelect.val('3P+N'); + else phasesSelect.val(''); // Simple line for N, PE, network cables + }); + + // Save + $('#rail-save').on('click', function() { + self.saveRail(carrierId); + }); + + // Close + $('#rail-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-rail-dialog').remove(); + }); + }, + + saveRail: function(carrierId) { + var self = this; + + // position_y: -1 = above equipment, 0+ = below equipment + var positionY = $('select[name="rail_position"]').val() === 'above' ? -1 : 0; + + var data = { + action: 'create_rail', + carrier_id: carrierId, + connection_type: $('input[name="rail_connection_type"]').val(), + color: $('input[name="rail_color"]').val(), + rail_start_te: $('input[name="rail_start_te"]').val(), + rail_end_te: $('input[name="rail_end_te"]').val(), + rail_phases: $('select[name="rail_phases"]').val(), + excluded_te: $('input[name="rail_excluded_te"]').val(), + position_y: positionY, + token: $('input[name="token"]').val() + }; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + method: 'POST', + data: data, + dataType: 'json', + success: function(response) { + if (response.success) { + $('#kundenkarte-rail-dialog').remove(); + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + showEditRailDialog: function(connectionId, carrierId) { + var self = this; + + if ($('#kundenkarte-rail-dialog').length) return; + + // Load connection data first + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + data: { action: 'get', connection_id: connectionId }, + dataType: 'json', + success: function(response) { + if (response.success && response.connection) { + self.renderEditRailDialog(connectionId, carrierId, response.connection); + } else { + KundenKarte.showAlert('Fehler', 'Verbindung nicht gefunden'); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + renderEditRailDialog: function(connectionId, carrierId, conn) { + var self = this; + + // Get carrier info + var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]'); + var totalTE = 12; + var infoText = $carrier.find('.kundenkarte-carrier-info').text(); + var match = infoText.match(/\/(\d+)/); + if (match) totalTE = parseInt(match[1]); + + var isAbove = conn.position_y < 0; + + var html = '
'; + html += '
'; + html += '

Sammelschiene bearbeiten

'; + html += '×
'; + html += '
'; + html += ''; + html += '
'; + + // Quick presets row + html += '
'; + html += ''; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '|'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Connection type (free text) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Position (above/below) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // TE range + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Multi-phase rail options + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Excluded positions (for FI switches etc.) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += 'An diesen Positionen wird die Schiene unterbrochen'; + html += '
'; + html += '
'; + + html += '
'; // form + html += '
'; // body + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-rail-dialog').addClass('visible'); + + // Preset button handlers + $('#kundenkarte-rail-dialog .rail-preset-btn').on('click', function() { + var type = $(this).data('type'); + $('input[name="rail_connection_type"]').val(type); + $('input[name="rail_color"]').val($(this).data('color')); + + // Auto-set rail_phases for electrical presets + var phasesSelect = $('select[name="rail_phases"]'); + if (type === 'L1') phasesSelect.val('L1'); + else if (type === 'L1N') phasesSelect.val('L1N'); + else if (type === '3P') phasesSelect.val('3P'); + else if (type === '3P+N') phasesSelect.val('3P+N'); + else phasesSelect.val(''); // Simple line for N, PE, network cables + }); + + // Save + $('#rail-save').on('click', function() { + self.updateRail(connectionId, carrierId); + }); + + // Delete + $('#rail-delete').on('click', function() { + $('#kundenkarte-rail-dialog').remove(); + self.showConfirmDialog('Sammelschiene löschen', 'Möchten Sie diese Sammelschiene wirklich löschen?', function() { + self.doDeleteConnection(connectionId); + }); + }); + + // Close + $('#rail-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-rail-dialog').remove(); + }); + }, + + updateRail: function(connectionId, carrierId) { + var self = this; + + var positionY = $('select[name="rail_position"]').val() === 'above' ? -1 : 0; + + var data = { + action: 'update', + connection_id: connectionId, + carrier_id: carrierId, + connection_type: $('input[name="rail_connection_type"]').val(), + color: $('input[name="rail_color"]').val(), + rail_start_te: $('input[name="rail_start_te"]').val(), + rail_end_te: $('input[name="rail_end_te"]').val(), + rail_phases: $('select[name="rail_phases"]').val(), + excluded_te: $('input[name="rail_excluded_te"]').val(), + position_y: positionY, + is_rail: 1, + token: $('input[name="token"]').val() + }; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + method: 'POST', + data: data, + dataType: 'json', + success: function(response) { + if (response.success) { + $('#kundenkarte-rail-dialog').remove(); + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + showEditOutputDialog: function(connectionId, carrierId) { + var self = this; + + if ($('#kundenkarte-output-dialog').length) return; + + // Load connection data first + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + data: { action: 'get', connection_id: connectionId }, + dataType: 'json', + success: function(response) { + if (response.success && response.connection) { + self.renderEditOutputDialog(connectionId, carrierId, response.connection); + } else { + KundenKarte.showAlert('Fehler', 'Verbindung nicht gefunden'); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + renderEditOutputDialog: function(connectionId, carrierId, conn) { + var self = this; + + var html = '
'; + html += '
'; + html += '

Abgang bearbeiten

'; + html += '×
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Quick presets + html += '
'; + html += ''; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '|'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Output label (consumer) + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Connection type and color + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Medium info + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // Length + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; // form + html += '
'; // body + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-output-dialog').addClass('visible'); + + // Preset button handlers + $('#kundenkarte-output-dialog .output-preset-btn').on('click', function() { + $('input[name="output_connection_type"]').val($(this).data('type')); + $('input[name="output_color"]').val($(this).data('color')); + }); + + // Save + $('#output-save').on('click', function() { + self.updateOutput(connectionId, carrierId); + }); + + // Delete + $('#output-delete').on('click', function() { + $('#kundenkarte-output-dialog').remove(); + self.showConfirmDialog('Abgang löschen', 'Möchten Sie diesen Abgang wirklich löschen?', function() { + self.doDeleteConnection(connectionId); + }); + }); + + // Close + $('#output-cancel, .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-output-dialog').remove(); + }); + }, + + updateOutput: function(connectionId, carrierId) { + var self = this; + + var data = { + action: 'update', + connection_id: connectionId, + carrier_id: carrierId, + fk_source: $('input[name="output_fk_source"]').val(), + source_terminal: 'output', + connection_type: $('input[name="output_connection_type"]').val(), + color: $('input[name="output_color"]').val(), + output_label: $('input[name="output_label"]').val(), + medium_type: $('input[name="output_medium_type"]').val(), + medium_spec: $('input[name="output_medium_spec"]').val(), + medium_length: $('input[name="output_medium_length"]').val(), + is_rail: 0, + token: $('input[name="token"]').val() + }; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + method: 'POST', + data: data, + dataType: 'json', + success: function(response) { + if (response.success) { + $('#kundenkarte-output-dialog').remove(); + location.reload(); + } else { + KundenKarte.showAlert('Fehler', response.error); + } + }, + error: function() { + KundenKarte.showAlert('Fehler', 'Netzwerkfehler'); + } + }); + }, + + deleteConnection: function(connectionId) { + var self = this; + + self.showConfirmDialog('Verbindung löschen', 'Möchten Sie diese Verbindung wirklich löschen?', function() { + self.doDeleteConnection(connectionId); + }); + }, + + // Internal function to delete connection without confirmation + doDeleteConnection: function(connectionId) { + var self = this; + + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + method: 'POST', + data: { + action: 'delete', + connection_id: connectionId, + token: $('input[name="token"]').val() + }, + dataType: 'json', + success: function(response) { + if (response.success) { + location.reload(); + } else { + self.showAlertDialog('Fehler', response.error); + } + } + }); + }, + + // Custom confirm dialog (replaces browser confirm()) + showConfirmDialog: function(title, message, onConfirm, onCancel) { + var self = this; + + // Remove any existing confirm dialog + $('#kundenkarte-confirm-dialog').remove(); + + var html = '
'; + html += '
'; + html += '

' + self.escapeHtml(title) + '

'; + html += '×
'; + html += '
'; + html += '

' + self.escapeHtml(message) + '

'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-confirm-dialog').addClass('visible'); + + // Focus yes button + $('#confirm-yes').focus(); + + // Yes button + $('#confirm-yes').on('click', function() { + $('#kundenkarte-confirm-dialog').remove(); + if (typeof onConfirm === 'function') { + onConfirm(); + } + }); + + // No button and close + $('#confirm-no, #kundenkarte-confirm-dialog .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-confirm-dialog').remove(); + if (typeof onCancel === 'function') { + onCancel(); + } + }); + + // ESC key + $(document).one('keydown.confirmDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-confirm-dialog').remove(); + if (typeof onCancel === 'function') { + onCancel(); + } + } else if (e.key === 'Enter') { + $('#kundenkarte-confirm-dialog').remove(); + if (typeof onConfirm === 'function') { + onConfirm(); + } + } + }); + }, + + // Custom alert dialog (replaces browser alert()) + showAlertDialog: function(title, message, onClose) { + var self = this; + + // Remove any existing alert dialog + $('#kundenkarte-alert-dialog').remove(); + + var html = '
'; + html += '
'; + html += '

' + self.escapeHtml(title) + '

'; + html += '×
'; + html += '
'; + html += '

' + self.escapeHtml(message) + '

'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-alert-dialog').addClass('visible'); + + $('#alert-ok').focus(); + + var closeDialog = function() { + $('#kundenkarte-alert-dialog').remove(); + $(document).off('keydown.alertDialog'); + if (typeof onClose === 'function') { + onClose(); + } + }; + + $('#alert-ok, #kundenkarte-alert-dialog .kundenkarte-modal-close').on('click', closeDialog); + + $(document).on('keydown.alertDialog', function(e) { + if (e.key === 'Escape' || e.key === 'Enter') { + closeDialog(); + } + }); + }, + + escapeHtml: function(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + // BOM (Bill of Materials) / Stückliste Dialog + showBOMDialog: function() { + var self = this; + var anlageId = this.anlageId; + + // Show loading + var html = '
'; + html += '
'; + html += '
'; + html += '

Stückliste (BOM)

'; + html += '×
'; + html += '
'; + html += '

Lade Stückliste...
'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-bom-dialog').addClass('visible'); + + // Close handler + $('#bom-close, #kundenkarte-bom-dialog .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-bom-dialog').remove(); + }); + + $(document).on('keydown.bomDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-bom-dialog').remove(); + $(document).off('keydown.bomDialog'); + } + }); + + // Load BOM data + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/bom_generator.php', + data: { action: 'generate', anlage_id: anlageId }, + dataType: 'json', + success: function(response) { + if (response.success) { + self.renderBOMContent(response); + } else { + $('.bom-loading').html('
' + (response.error || 'Fehler beim Laden') + '
'); + } + }, + error: function() { + $('.bom-loading').html('
AJAX Fehler
'); + } + }); + }, + + renderBOMContent: function(data) { + var self = this; + var $body = $('#kundenkarte-bom-dialog .kundenkarte-modal-body'); + + if (!data.summary || data.summary.length === 0) { + $body.html('


Keine Materialien im Schaltplan gefunden.
Fügen Sie Equipment hinzu oder verknüpfen Sie Produkte.
'); + return; + } + + var html = ''; + + // Summary table (grouped by product) + html += '

Zusammenfassung nach Produkt

'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + var totalWithPrice = 0; + var totalQuantity = 0; + + data.summary.forEach(function(item, index) { + var rowClass = index % 2 === 0 ? 'oddeven' : 'oddeven'; + var hasProduct = item.product_id ? true : false; + var priceCell = hasProduct && item.price ? self.formatPrice(item.price) + ' €' : '-'; + var totalCell = hasProduct && item.total ? self.formatPrice(item.total) + ' €' : '-'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + totalQuantity += item.quantity; + if (hasProduct && item.total) { + totalWithPrice += item.total; + } + }); + + // Totals row + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + html += '
ReferenzBezeichnungMengeEinzelpreisGesamt
' + self.escapeHtml(item.product_ref || '-') + '' + self.escapeHtml(item.product_label || '-'); + if (!hasProduct) { + html += ' (kein Produkt verknüpft)'; + } + html += '' + item.quantity + '' + priceCell + '' + totalCell + '
Summe' + totalQuantity + ' Stück' + self.formatPrice(totalWithPrice) + ' €
'; + + // Detailed list (collapsible) + if (data.items && data.items.length > 0) { + html += '
'; + html += ' Detailliste (' + data.items.length + ' Einträge)'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + data.items.forEach(function(item, index) { + var rowClass = index % 2 === 0 ? 'oddeven' : 'oddeven'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
EquipmentTypFeld/HutschieneBreiteProdukt
' + self.escapeHtml(item.equipment_label || '-') + '' + self.escapeHtml(item.type_ref || '') + ' ' + self.escapeHtml(item.type_label || '-') + '' + self.escapeHtml(item.panel_label || '-') + ' / ' + self.escapeHtml(item.carrier_label || '-') + '' + (item.width_te || 1) + ' TE' + (item.product_ref ? self.escapeHtml(item.product_ref) : '-') + '
'; + html += '
'; + } + + // Export buttons + html += '
'; + html += ''; + html += '
'; + + $body.html(html); + + // Update totals in footer + $('.bom-totals').html('' + totalQuantity + ' Artikel | ' + self.formatPrice(totalWithPrice) + ' € (geschätzt)'); + + // Copy to clipboard + $body.find('.bom-copy-clipboard').on('click', function() { + var text = 'Stückliste\n\n'; + text += 'Referenz\tBezeichnung\tMenge\tEinzelpreis\tGesamt\n'; + data.summary.forEach(function(item) { + text += (item.product_ref || '-') + '\t'; + text += (item.product_label || '-') + '\t'; + text += item.quantity + '\t'; + text += (item.price ? self.formatPrice(item.price) + ' €' : '-') + '\t'; + text += (item.total ? self.formatPrice(item.total) + ' €' : '-') + '\n'; + }); + text += '\nSumme:\t\t' + totalQuantity + ' Stück\t\t' + self.formatPrice(totalWithPrice) + ' €'; + + navigator.clipboard.writeText(text).then(function() { + KundenKarte.showNotification('In Zwischenablage kopiert', 'success'); + }).catch(function() { + KundenKarte.showError('Fehler', 'Kopieren nicht möglich'); + }); + }); + }, + + formatPrice: function(price) { + if (!price) return '0,00'; + return parseFloat(price).toFixed(2).replace('.', ','); + }, + + // Audit Log Dialog + showAuditLogDialog: function() { + var self = this; + var anlageId = this.anlageId; + + var html = '
'; + html += '
'; + html += '
'; + html += '

Änderungsprotokoll

'; + html += '×
'; + html += '
'; + html += '

Lade Protokoll...
'; + html += '
'; + html += ''; + html += '
'; + + $('body').append(html); + $('#kundenkarte-audit-dialog').addClass('visible'); + + // Close handler + $('#audit-close, #kundenkarte-audit-dialog .kundenkarte-modal-close').on('click', function() { + $('#kundenkarte-audit-dialog').remove(); + }); + + $(document).on('keydown.auditDialog', function(e) { + if (e.key === 'Escape') { + $('#kundenkarte-audit-dialog').remove(); + $(document).off('keydown.auditDialog'); + } + }); + + // Load audit data + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/audit_log.php', + data: { action: 'fetch_anlage', anlage_id: anlageId, limit: 100 }, + dataType: 'json', + success: function(response) { + if (response.success) { + self.renderAuditContent(response.logs); + } else { + $('.audit-loading').html('
' + (response.error || 'Fehler beim Laden') + '
'); + } + }, + error: function() { + $('.audit-loading').html('
AJAX Fehler
'); + } + }); + }, + + renderAuditContent: function(logs) { + var self = this; + var $body = $('#kundenkarte-audit-dialog .kundenkarte-modal-body'); + + if (!logs || logs.length === 0) { + $body.html('


Keine Änderungen protokolliert.
'); + return; + } + + var html = '
'; + + logs.forEach(function(log) { + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += '
'; + html += '
'; + html += '' + self.escapeHtml(log.action_label) + ''; + html += '' + self.escapeHtml(log.date_action) + ''; + html += '
'; + html += '
'; + html += '' + self.escapeHtml(log.object_type_label) + ' '; + html += self.escapeHtml(log.object_ref || 'ID ' + log.object_id); + html += '
'; + + if (log.field_changed) { + html += '
'; + html += 'Feld: ' + self.escapeHtml(log.field_changed); + if (log.old_value || log.new_value) { + html += ' (' + self.escapeHtml(log.old_value || '-') + '' + self.escapeHtml(log.new_value || '-') + ')'; + } + html += '
'; + } + + if (log.note) { + html += '
' + self.escapeHtml(log.note) + '
'; + } + + html += '
'; + html += ' ' + self.escapeHtml(log.user_name || log.user_login); + html += '
'; + html += '
'; + }); + + html += '
'; + + $body.html(html); + } + }; + + /** + * Connection Editor Component + * Interactive SVG-based connection editor with orthogonal routing + * Supports busbars, multi-phase connections, and drag-drop + */ + KundenKarte.ConnectionEditor = { + TE_WIDTH: 50, + BLOCK_HEIGHT: 110, + BUSBAR_HEIGHT: 25, + BUSBAR_SPACING: 8, + CONNECTION_AREA_HEIGHT: 120, + ENDPOINT_RADIUS: 6, + + // Phase colors (German electrical standard) + PHASE_COLORS: { + 'L1': '#8B4513', // Brown + 'L2': '#000000', // Black + 'L3': '#808080', // Gray + 'N': '#0066cc', // Blue + 'PE': '#27ae60', // Green-Yellow + 'L1N': '#3498db', // Combined + '3P': '#2c3e50', // 3-phase + '3P+N': '#34495e' // 3-phase + neutral + }, + + // State + isExpanded: false, + currentCarrierId: null, + currentAnlageId: null, + connections: [], + busbars: [], + equipment: [], + dragState: null, + selectedConnection: null, + + init: function(anlageId) { + console.log('ConnectionEditor.init called with anlageId:', anlageId); + if (!anlageId) { + console.log('ConnectionEditor: No anlageId, aborting'); + return; + } + this.currentAnlageId = anlageId; + + // Restore expanded state from localStorage + var savedState = localStorage.getItem('kundenkarte_connection_editor_expanded'); + this.isExpanded = savedState === 'true'; + console.log('ConnectionEditor: isExpanded =', this.isExpanded); + + this.bindEvents(); + console.log('ConnectionEditor: Events bound'); + + this.renderAllEditors(); + console.log('ConnectionEditor: Editors rendered'); + }, + + bindEvents: function() { + var self = this; + + // Toggle editor visibility + $(document).on('click', '.kundenkarte-connection-editor-toggle', function(e) { + e.preventDefault(); + var $editor = $(this).closest('.kundenkarte-carrier').find('.kundenkarte-connection-editor'); + var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id'); + + $editor.toggleClass('expanded'); + $(this).find('i').toggleClass('fa-chevron-down fa-chevron-up'); + + // Save state + self.isExpanded = $editor.hasClass('expanded'); + localStorage.setItem('kundenkarte_connection_editor_expanded', self.isExpanded); + + if (self.isExpanded) { + self.loadAndRenderEditor(carrierId); + } + }); + + // Add busbar button + $(document).on('click', '.kundenkarte-add-busbar-btn', function(e) { + e.preventDefault(); + var carrierId = $(this).data('carrier-id'); + self.showBusbarDialog(carrierId); + }); + + // Add connection button + $(document).on('click', '.kundenkarte-add-conn-btn', function(e) { + e.preventDefault(); + var carrierId = $(this).data('carrier-id'); + self.showConnectionDialog(carrierId); + }); + + // SVG mouse events for drag-drop connections + $(document).on('mousedown', '.kundenkarte-endpoint', function(e) { + e.preventDefault(); + e.stopPropagation(); + var $endpoint = $(this); + var carrierId = $endpoint.closest('.kundenkarte-connection-editor').data('carrier-id'); + + self.startDragConnection(e, $endpoint, carrierId); + }); + + $(document).on('mousemove', function(e) { + if (self.dragState) { + self.updateDragConnection(e); + } + }); + + $(document).on('mouseup', function(e) { + if (self.dragState) { + self.endDragConnection(e); + } + }); + + // Click on busbar to edit + $(document).on('click', '.kundenkarte-busbar-element', function(e) { + e.preventDefault(); + e.stopPropagation(); + var connectionId = $(this).data('connection-id'); + var carrierId = $(this).closest('.kundenkarte-connection-editor').data('carrier-id'); + self.showEditBusbarDialog(connectionId, carrierId); + }); + + // Click on connection line to edit/delete + $(document).on('click', '.kundenkarte-connection-path', function(e) { + e.preventDefault(); + e.stopPropagation(); + var connectionId = $(this).data('connection-id'); + var carrierId = $(this).closest('.kundenkarte-connection-editor').data('carrier-id'); + self.showEditConnectionDialog(connectionId, carrierId); + }); + + // Hover effects + $(document).on('mouseenter', '.kundenkarte-endpoint', function() { + $(this).addClass('hover'); + }); + $(document).on('mouseleave', '.kundenkarte-endpoint', function() { + $(this).removeClass('hover'); + }); + }, + + renderAllEditors: function() { + var self = this; + $('.kundenkarte-carrier').each(function() { + var carrierId = $(this).data('carrier-id'); + var $editor = $(this).find('.kundenkarte-connection-editor'); + + // Editor is already rendered via PHP, just need to initialize state + if ($editor.length) { + if (self.isExpanded) { + $editor.addClass('expanded'); + $(this).find('.kundenkarte-connection-editor-toggle i').removeClass('fa-chevron-down').addClass('fa-chevron-up'); + self.loadAndRenderEditor(carrierId); + } + } + }); + }, + + loadAndRenderEditor: function(carrierId) { + var self = this; + + // Load equipment for this carrier + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment.php', + data: { action: 'list', carrier_id: carrierId }, + dataType: 'json', + success: function(response) { + if (response.success) { + self.equipment = response.equipment || []; + + // Load connections + $.ajax({ + url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php', + data: { action: 'list', carrier_id: carrierId }, + dataType: 'json', + success: function(connResponse) { + if (connResponse.success) { + self.connections = connResponse.connections || []; + self.busbars = self.connections.filter(function(c) { return c.is_rail == 1; }); + self.renderEditor(carrierId); + } + } + }); + } + } + }); + }, + + renderEditor: function(carrierId) { + var self = this; + var $editor = $('.kundenkarte-connection-editor[data-carrier-id="' + carrierId + '"]'); + var $svg = $editor.find('.kundenkarte-connection-svg'); + var totalTE = parseInt($editor.data('total-te')) || 12; + var totalWidth = totalTE * this.TE_WIDTH; + + // Reset busbar positions + this.busbarPositions = {}; + + var svgContent = ''; + + // Defs for markers and gradients + svgContent += ''; + svgContent += ''; + svgContent += ''; + svgContent += ''; + + // Phase gradients + Object.keys(this.PHASE_COLORS).forEach(function(phase) { + svgContent += ''; + svgContent += ''; + svgContent += ''; + svgContent += ''; + }); + svgContent += ''; + + // If no busbars exist, show a demo + if (this.busbars.length === 0 && this.equipment.length > 0) { + svgContent += this.renderDemoView(carrierId, totalTE, totalWidth); + $svg.html(svgContent); + $svg.attr('height', 200); + return; + } + + // Background grid + for (var i = 0; i <= totalTE; i++) { + var x = i * this.TE_WIDTH; + svgContent += ''; + } + + // Render busbars + var busbarY = 15; + var busbarsByPosition = {}; + + this.busbars.forEach(function(busbar, index) { + var posKey = busbar.position_y < 0 ? 'above' : busbar.position_y; + if (!busbarsByPosition[posKey]) busbarsByPosition[posKey] = []; + busbarsByPosition[posKey].push(busbar); + }); + + Object.keys(busbarsByPosition).forEach(function(posKey) { + busbarsByPosition[posKey].forEach(function(busbar, idx) { + svgContent += self.renderBusbar(busbar, carrierId, busbarY + (idx * (self.BUSBAR_HEIGHT + self.BUSBAR_SPACING))); + }); + busbarY += busbarsByPosition[posKey].length * (self.BUSBAR_HEIGHT + self.BUSBAR_SPACING); + }); + + // Render equipment endpoints + var equipmentY = busbarY + 20; + + // Store equipment positions for connection routing + this.equipmentPositions = {}; + this.equipment.forEach(function(eq) { + var width = eq.width_te * self.TE_WIDTH; + var centerX = (eq.position_te - 1) * self.TE_WIDTH + width / 2; + self.equipmentPositions[eq.id] = { + x: centerX, + left: (eq.position_te - 1) * self.TE_WIDTH, + right: eq.position_te * self.TE_WIDTH + (eq.width_te - 1) * self.TE_WIDTH, + inputY: equipmentY - 15, + outputY: equipmentY + 15, + centerY: equipmentY + }; + }); + + // Render non-busbar connections FIRST (behind equipment) + // But route them around blocks, not through them + var regularConnections = this.connections.filter(function(c) { return c.is_rail != 1; }); + + // Connection layer - rendered below equipment layer + svgContent += ''; + regularConnections.forEach(function(conn, index) { + svgContent += self.renderOrthogonalConnection(conn, carrierId, equipmentY, index); + }); + svgContent += ''; + + // Equipment layer - rendered on top + svgContent += ''; + this.equipment.forEach(function(eq) { + svgContent += self.renderEquipmentEndpoints(eq, equipmentY, carrierId); + }); + svgContent += ''; + + // Render drag preview line (hidden by default) - on top of everything + svgContent += '