UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1,053 lines (1,005 loc) 107 kB
import * as i0 from '@angular/core'; import { Injectable, inject, DestroyRef, input, computed, ChangeDetectionStrategy, Component, viewChild, signal, ElementRef, output, effect } from '@angular/core'; import { takeUntilDestroyed, toSignal, toObservable } from '@angular/core/rxjs-interop'; import * as i2 from '@c8y/ngx-components'; import { IconDirective, C8yTranslatePipe, AlertService, DropAreaComponent, FormGroupComponent, ListGroupModule, ModalModule, FormsModule as FormsModule$1, DashboardChildComponent, DynamicComponentAlertAggregator, DismissAlertStrategy, DynamicComponentAlert, ManagedObjectRealtimeService } from '@c8y/ngx-components'; import { gettext } from '@c8y/ngx-components/gettext'; import { WidgetConfigService, WidgetConfigSectionComponent, AssetPropertyMappingsService, quoteAndEscape, renamePropertyKeyInCode, escapeForRegExp } from '@c8y/ngx-components/context-dashboard'; import { Subject, debounceTime, switchMap, startWith, shareReplay, firstValueFrom, tap, filter, retry, take, map } from 'rxjs'; import { InventoryService, InventoryBinaryService } from '@c8y/client'; import { ComputedPropertiesService } from '@c8y/ngx-components/asset-properties'; import { FieldbusService } from '@c8y/ngx-components/computed-asset-properties'; import { WidgetConfigMigrationService, GLOBAL_CONTEXT_DISPLAY_MODE, PRESET_NAME, GlobalContextConnectorComponent, LocalControlsComponent } from '@c8y/ngx-components/global-context'; import { merge, camelCase } from 'lodash-es'; import { SVG_EXTRACT_TAG_NAME, MAPPING_EXTRACT_TAG_NAME, SCADA_SVG_AGENT_DEFINITION, SCADA_OUTPUT_TAG_NAME } from '@c8y/ngx-components/ai/agents/scada'; import * as i1 from '@angular/forms'; import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { A11yModule } from '@angular/cdk/a11y'; import { AIService } from '@c8y/ngx-components/ai'; import { AgentChatComponent } from '@c8y/ngx-components/ai/agent-chat'; import { DomSanitizer } from '@angular/platform-browser'; import { TooltipDirective } from 'ngx-bootstrap/tooltip'; import { BsModalService } from 'ngx-bootstrap/modal'; import { HtmlFrameComponent, WidgetCodeEditorComponent, HtmlWidgetConfigService } from '@c8y/ngx-components/widgets/implementations/html-widget'; import { AsyncPipe } from '@angular/common'; const SCADA_WIDGET_FEATURE_FLAG_NAME = 'ui.scada-widget.v2'; const defaultDisplayOption = 'full-width'; const defaultAlignment = 'center'; const INITIAL_SVG_CODE = `<?xml version="1.0" encoding="utf-8"?> <svg width="600px" height="1350px" viewBox="0 0 600 1350" xmlns="http://www.w3.org/2000/svg"> <style> /* Typography */ .header { font-family: var(--c8y-font-family-base); font-size: 28px; font-weight: bold; fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default))); } .branded { fill: var(--brand-primary,var(--c8y-brand-primary)); } .step-number { font-family: var(--c8y-font-family-base); font-size: 18px; font-weight: bold; fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default)); } .step-title { font-family: var(--c8y-font-family-base); font-size: 16px; font-weight: bold; fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default))); } .description { font-family: var(--c8y-font-family-base); font-size: 14px; } .placeholder { font-family: var(--c8y-font-family-base); font-size: 14px; fill: var(--brand-primary,var(--c8y-brand-primary)); font-weight: bold; } .code { font-family: var(--c8y-font-family-mono-spaced); font-size: 12px; fill: var(--c8y-code-color); } .label { font-family: var(--c8y-font-family-base); font-size: 13px; fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default))); } /* Components */ .section-bg { fill: var(--c8y-pre-background); stroke: var(--c8y-pre-border-color); stroke-width: 1px; rx: 4px; } .step-circle { fill: var(--brand-primary,var(--c8y-brand-primary)); } .code-bg { fill: #0000000d; stroke: transparent; stroke-width: 1px; rx: 13px; } .button{ cursor: pointer; } .button-bg { fill: var(--brand-primary,var(--c8y-brand-primary)); rx: var(--c8y-btn-border-radius-base); stroke: var(--brand-primary,var(--c8y-brand-primary)); stroke-width: 1px; } .button-text { font-family: inherit; font-size: 14px; fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default)); text-anchor: middle; } .button:hover .button-bg { fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default)); } .button:hover .button-text { fill: var(--brand-primary,var(--c8y-brand-primary)); } .divider { stroke: var(--c8y-component-border-color, var(--c8y-root-component-border-color)); stroke-width: 2px; } /* Alarm status indicators */ .alarm-none { fill: var(--c8y-palette-status-success); } .alarm-warning { fill: var(--c8y-palette-status-info); } .alarm-minor { fill: var(--c8y-palette-status-warning-high); } .alarm-major { fill: var(--c8y-palette-status-warning); } .alarm-critical { fill: var(--c8y-palette-status-danger); } /* Conditional text colors */ .temp-ok { fill: var(--c8y-palette-status-success); } .temp-warning { fill:var(--c8y-palette-status-danger); } </style> <!-- Background --> <rect width="600" height="1400" fill="var(--c8y-component-background-default, var(--c8y-root-component-background-default))" /> <!-- Header --> <text x="40" y="50" class="header">SCADA widget example</text> <text x="40" y="90" class="description">Follow these steps to customize your visualization</text> <!-- Divider --> <line x1="40" y1="110" x2="560" y2="110" class="divider" /> <!-- STEP 1: SVG configuration --> <circle cx="55" cy="150" r="15" class="step-circle" /> <text x="55" y="157" class="step-number" text-anchor="middle">1</text> <text x="85" y="155" class="step-title">SVG configuration</text> <text x="85" y="180" class="description">Use "SVG configuration" section to upload a file, paste code, or generate with AI.</text> <!-- STEP 2: Add Placeholders --> <circle cx="55" cy="230" r="15" class="step-circle" /> <text x="55" y="237" class="step-number" text-anchor="middle">2</text> <text x="85" y="235" class="step-title">Add placeholders</text> <text x="85" y="260" class="description">Click on a text or tspan element in the preview to convert it into a placeholder.</text> <rect x="85" y="280" width="475" height="80" class="section-bg" /> <text x="100" y="305" class="label">Example - click text below to make it dynamic:</text> <text x="100" y="330" class="placeholder"><tspan>Device name</tspan> (<tspan>Device ID</tspan>)</text> <!-- STEP 3: Configure Data Binding --> <circle cx="55" cy="395" r="15" class="step-circle" /> <text x="55" y="402" class="step-number" text-anchor="middle">3</text> <text x="85" y="400" class="step-title">Configure data binding</text> <text x="85" y="425" class="description">Assign asset properties to placeholders in the "Placeholder mappings" section</text> <text x="85" y="445" class="description">to see their values in the preview.</text> <!-- Example with actual placeholders --> <rect x="40" y="470" width="520" height="120" class="section-bg" /> <text x="60" y="495" class="label">Examples:</text> <text x="60" y="525" class="label">Device name:</text> <text x="180" y="525" class="placeholder">\${this.c8yScadaValues?.deviceName || '-'}</text> <text x="60" y="550" class="label">Temperature:</text> <text x="180" y="550" class="placeholder">\${this.c8yScadaValues?.temperature || '-'} °C</text> <text x="60" y="575" class="label">Status:</text> <text x="180" y="575" class="placeholder">\${this.c8yScadaValues?.status || '-'}</text> <!-- STEP 4: Advanced Customization --> <circle cx="55" cy="630" r="15" class="step-circle" /> <text x="55" y="637" class="step-number" text-anchor="middle">4</text> <text x="85" y="635" class="step-title">Advanced: Edit component code</text> <text x="85" y="660" class="description">Open "Advanced editor" section to customize the web component directly.</text> <!-- Advanced Examples --> <rect x="40" y="690" width="520" height="630" class="section-bg" /> <text x="60" y="715" class="label">Advanced examples (see implementation in the editor):</text> <!-- Example 1a: Interactive Button - Device Details --> <text x="60" y="745" class="description">• Button navigating to device details:</text> <g class="button" @click=\${() => this.c8yScadaFunctions?.goToDeviceDetails(this.c8yScadaValues?.deviceId)}> <rect x="70" y="755" width="200" height="35" class="button-bg" /> <text x="170" y="777" class="button-text">Go to device details</text> </g> <!-- Example 1b: Interactive Button - Group Details --> <text x="60" y="820" class="description">• Button navigating to group details:</text> <g class="button" @click=\${() => this.c8yScadaFunctions?.goToGroupDetails(this.c8yScadaValues?.groupId)}> <rect x="70" y="830" width="200" height="35" class="button-bg" /> <text x="170" y="852" class="button-text">Go to group details</text> </g> <!-- Example 2: Conditional Styling --> <text x="60" y="895" class="description">• Conditional styling based on temperature range:</text> <text x="70" y="920" class="\${(this.c8yScadaValues?.temperature >= 20 && this.c8yScadaValues?.temperature <= 30) ? 'temp-ok' : 'temp-warning'}" font-size="14px" font-weight="bold"> Green if temperature is between 20 and 30 °C, otherwise red. </text> <!-- Example 3: Dynamic Width Progress Bar --> <text x="60" y="975" class="description">• Dynamic calculations (progress bar):</text> <rect x="70" y="985" width="400" height="25" class="code-bg" /> <rect x="70" y="985" width="\${Math.min((this.c8yScadaValues?.progress || 0) * 4, 400)}" height="25" fill="var(--brand-primary,var(--c8y-brand-primary))" rx="13" /> <text x="480" y="1000" class="label">\${this.c8yScadaValues?.progress || 0}%</text> <!-- Example 4: Alarm Status Circle --> <text x="60" y="1040" class="description">• Alarm status indicator using getActiveAlarmsStatusClass():</text> <circle cx="80" cy="1065" r="12" class="\${this.c8yScadaFunctions?.getActiveAlarmsStatusClass ? 'alarm-' + this.c8yScadaFunctions.getActiveAlarmsStatusClass(this.c8yScadaValues?.alarmsStatus) : 'alarm-none'}" /> <text x="100" y="1070" class="label"> Alarm status: \${this.c8yScadaValues?.alarmsStatus?.critical > 0 ? 'CRITICAL' : this.c8yScadaValues?.alarmsStatus?.major > 0 ? 'MAJOR' : this.c8yScadaValues?.alarmsStatus?.minor > 0 ? 'MINOR' : this.c8yScadaValues?.alarmsStatus?.warning > 0 ? 'WARNING' : 'OK'} </text> <!-- Legend for alarm colors --> <text x="70" y="1110" class="label">Alarm color legend:</text> <circle cx="80" cy="1130" r="8" class="alarm-none" /> <text x="95" y="1135" class="description">OK</text> <circle cx="150" cy="1130" r="8" class="alarm-warning" /> <text x="165" y="1135" class="description">Warning</text> <circle cx="240" cy="1130" r="8" class="alarm-minor" /> <text x="255" y="1135" class="description">Minor</text> <circle cx="320" cy="1130" r="8" class="alarm-major" /> <text x="335" y="1135" class="description">Major</text> <circle cx="400" cy="1130" r="8" class="alarm-critical" /> <text x="415" y="1135" class="description">Critical</text> <!-- Additional examples info --> <rect x="70" y="1170" width="460" height="130" fill="var(--c8y-palette-status-info-light)" stroke="var(--c8y-palette-status-info)" stroke-width="1" rx="4" /> <text x="85" y="1195" class="label" font-weight="bold">💡 Tips for advanced usage:</text> <text x="85" y="1220" class="description">• Use this.c8yScadaValues to access placeholder values</text> <text x="85" y="1240" class="description">• Use this.c8yScadaFunctions for navigation and utilities</text> <text x="85" y="1260" class="description">• Combine template literals \\\${ } with conditional logic</text> <text x="85" y="1280" class="description">• Apply CSS classes dynamically for styling</text> </svg> `; class ScadaWidgetService { getScadaFunctions() { return { goToGroupDetails: (groupId) => { window.location.hash = `/group/${groupId}`; }, goToDeviceDetails: (deviceId) => { window.location.hash = `/device/${deviceId}`; }, getActiveAlarmsStatusClass: (activeAlarmsStatus) => { const severities = [ 'critical', 'major', 'minor', 'warning' ]; for (const severity of severities) { if (activeAlarmsStatus?.[severity] > 0) { return severity; } } return 'none'; } }; } getSvgComputedStyling(settings) { const displayOption = settings?.displayOption ?? 'full-width'; const hAlign = settings?.horizontalAlignment ?? 'center'; const vAlign = settings?.verticalAlignment ?? 'center'; if (!settings || displayOption === 'full-width') { return { svgWidth: '100%', svgHeight: '', svgMaxWidth: '', svgMaxHeight: '', preserveAspectRatio: null, containerDisplay: '', containerJustifyContent: '', containerAlignItems: '', containerOverflow: '', containerHeight: '100%', containerBoxSizing: 'border-box' }; } const hFlex = { left: 'flex-start', center: 'center', right: 'flex-end' }; const vFlex = { top: 'flex-start', center: 'center', bottom: 'flex-end' }; const hPar = { left: 'Min', center: 'Mid', right: 'Max' }; const vPar = { top: 'Min', center: 'Mid', bottom: 'Max' }; const parBase = `x${hPar[hAlign]}Y${vPar[vAlign]}`; // contain return { svgWidth: '', svgHeight: '', svgMaxWidth: '100%', svgMaxHeight: '100%', preserveAspectRatio: `${parBase} meet`, containerDisplay: 'flex', containerJustifyContent: hFlex[hAlign], containerAlignItems: vFlex[vAlign], containerOverflow: '', containerHeight: '100%', containerBoxSizing: 'border-box' }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService, decorators: [{ type: Injectable }] }); /** * Escapes Lit syntax to safe data attributes for XML parsing. * * Converts: * - @click=${...} => data-lit-event-click="${...}" * - ?attr=${...} => data-lit-attribute-attr="${...}" * - .prop=${...} => data-lit-property-prop="${...}" * - `"` inside expressions => `&quot;` (to produce valid XML/HTML attribute values) * * @param svg The SVG string containing Lit syntax * @returns The SVG string with escaped Lit syntax */ function escapeLitSyntax(svg) { return (svg // @click=${...} => data-lit-event-click="${...}" .replace(/@([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => { const escaped = expr.replace(/"/g, '&quot;'); return `data-lit-event-${name}="\${${escaped}}"`; }) // ?attr=${...} => data-lit-attribute-attr="${...}" .replace(/\?([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => { const escaped = expr.replace(/"/g, '&quot;'); return `data-lit-attribute-${name}="\${${escaped}}"`; }) // .prop=${...} => data-lit-property-prop="${...}" .replace(/\.([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => { const escaped = expr.replace(/"/g, '&quot;'); return `data-lit-property-${name}="\${${escaped}}"`; })); } /** * Unescapes data attributes back to Lit syntax. * * Converts: * - data-lit-event-click="${...}" => @click=${...} * - data-lit-attribute-attr="${...}" => ?attr=${...} * - data-lit-property-prop="${...}" => .prop=${...} * - `&quot;` inside expression values => `"` (decoded automatically via `DOMParser`) * * @param svg The SVG string with escaped data attributes * @returns The SVG string with restored Lit syntax */ function unescapeLitSyntax(svg) { return (svg // data-lit-event-click="${...}" => @click=${...} .replace(/data-lit-event-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => { const valueDocument = new DOMParser().parseFromString(value, 'text/html'); const { textContent } = valueDocument.documentElement; return `@${name}=${textContent}`; }) // data-lit-attribute-attr="${...}" => ?attr=${...} .replace(/data-lit-attribute-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => { const valueDocument = new DOMParser().parseFromString(value, 'text/html'); const { textContent } = valueDocument.documentElement; return `?${name}=${textContent}`; }) // data-lit-property-prop="${...}" => .prop=${...} .replace(/data-lit-property-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => { const valueDocument = new DOMParser().parseFromString(value, 'text/html'); const { textContent } = valueDocument.documentElement; return `.${name}=${textContent}`; })); } /** * Removes Lit expressions from SVG and replaces them with placeholders. * * Converts ${...} to __LIT_EXPR_n__ to allow safe XML parsing. * The extracted expressions are stored in the provided array for later restoration. * * @param svg The SVG string containing Lit expressions * @param litExpressions Array to store extracted expressions * @returns The SVG string with expressions replaced by placeholders */ function removeLitExpressions(svg, litExpressions) { // ${...} => __LIT_EXPR_n__ let exprIndex = 0; return svg.replace(/\$\{([^}]*)\}/g, (_, expr) => { litExpressions.push(expr); return `__LIT_EXPR_${exprIndex++}__`; }); } /** * Restores Lit expressions in SVG from placeholders. * * Converts __LIT_EXPR_n__ back to ${...} using expressions from the provided array. * * @param svg The SVG string with expression placeholders * @param litExpressions Array containing the extracted expressions * @returns The SVG string with restored Lit expressions */ function restoreLitExpressions(svg, litExpressions) { // __LIT_EXPR_n__ => ${...} return svg.replace(/__LIT_EXPR_(\d+)__/g, (_, index) => { const expr = litExpressions[Number(index)]; return `\${${expr}}`; }); } /** * Generates Lit web component code for rendering SCADA SVG elements. * * Creates a custom element class that extends LitElement with support for: * - Dynamic context binding (c8yContext) * - SCADA value placeholders (c8yScadaValues) * - SCADA helper functions (c8yScadaFunctions) * - Interactive placeholder highlighting and selection * * The generated component includes event listeners for hover and click interactions * on elements with the 'potential-placeholder' class, enabling users to identify * and select placeholders within the SCADA visualization in the widget's configuration. * * @param svgCode The SVG markup to embed in the component's render method * @returns A string containing the complete Lit web component code */ function getWebComponentCode(svgCode) { return ` import { LitElement, html, css } from 'lit'; export default class ScadaElement extends LitElement { static styles = css\` :host > div { padding: var(--c8y-root-component-padding-default); } .enable-potential-placeholders .potential-placeholder { cursor: pointer; transition: fill 0.2s, stroke 0.2s; pointer-events: auto; } .enable-potential-placeholders .potential-placeholder.hovered { fill: var(--brand-primary, var(--c8y-brand-primary)); stroke: var(--brand-primary, var(--c8y-brand-primary)); } \`; static properties = { // Context managed object assigned to this widget, might be null. c8yContext: { type: Object }, // Object with values assigned to placeholders in the widget, might be null. c8yScadaValues: { type: Object }, // Object with helper functions, might be null. c8yScadaFunctions: { type: Object }, // The instant translation function. c8yTranslate: { type: Function }, // Pre-computed SVG display and alignment styling. c8ySvgComputedStyling: { type: Object }, // Whether to highlight potential placeholders and emit click events. enablePotentialPlaceholders: { type: Boolean }, }; constructor() { super(); } get _wrapperStyle() { const s = this.c8ySvgComputedStyling; if (!s || !s.containerDisplay) { return ''; } let style = 'display: ' + s.containerDisplay + '; height: ' + s.containerHeight + '; justify-content: ' + s.containerJustifyContent + '; align-items: ' + s.containerAlignItems; if (s.containerOverflow) { style += '; overflow: ' + s.containerOverflow; } if (s.containerBoxSizing) { style += '; box-sizing: ' + s.containerBoxSizing; } return style; } _applySvgStyling() { const svg = this.renderRoot.querySelector('svg'); if (!svg) return; const s = this.c8ySvgComputedStyling; if (!s) { svg.style.width = '100%'; svg.style.height = ''; svg.style.maxWidth = ''; svg.style.maxHeight = ''; svg.removeAttribute('preserveAspectRatio'); return; } svg.style.width = s.svgWidth; svg.style.height = s.svgHeight; svg.style.maxWidth = s.svgMaxWidth; svg.style.maxHeight = s.svgMaxHeight; if (s.preserveAspectRatio === null) { svg.removeAttribute('preserveAspectRatio'); } else { svg.setAttribute('preserveAspectRatio', s.preserveAspectRatio); } } firstUpdated() { // Add hover and click listeners to potential placeholders const svg = this.renderRoot.querySelector('svg'); if (svg) { svg.querySelectorAll('.potential-placeholder').forEach(node => { const dispatchPlaceholderClickEvent = () => { const event = new CustomEvent('potential-placeholder-click', { detail: { node }, bubbles: true, composed: true }); this.dispatchEvent(event); }; node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button'); node.addEventListener('mouseenter', () => { node.classList.add('hovered'); }); node.addEventListener('mouseleave', () => { node.classList.remove('hovered'); }); node.addEventListener('click', () => { dispatchPlaceholderClickEvent(); }); node.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); dispatchPlaceholderClickEvent(); } }); }); } this._applySvgStyling(); } updated(changedProperties) { if (changedProperties.has('c8ySvgComputedStyling')) { this._applySvgStyling(); } } render() { return html\` <div class="\${this.enablePotentialPlaceholders ? 'enable-potential-placeholders' : ''}" style="\${this._wrapperStyle}"> ${svgCode} </div> \`; } }`; } class ScadaWidgetConfigService { constructor() { this.widgetConfigService = inject(WidgetConfigService); this.widgetConfigMigration = inject(WidgetConfigMigrationService); this.inventory = inject(InventoryService); this.inventoryBinary = inject(InventoryBinaryService); this.computedPropertiesService = inject(ComputedPropertiesService); this.fieldbusService = inject(FieldbusService); this.destroyRef = inject(DestroyRef); this.configChanged$ = new Subject(); this.config$ = this.widgetConfigService.currentConfig$.pipe(debounceTime(100), switchMap(config => this.initializeConfig(config)), switchMap(initConfig => this.configChanged$.pipe(startWith(initConfig))), shareReplay({ bufferSize: 1, refCount: true }), takeUntilDestroyed(this.destroyRef)); this.destroyRef.onDestroy(() => this.configChanged$.complete()); } async initializeConfig(config) { let migratedConfig = await this.migrateConfigIfNeeded(config); if (!migratedConfig.config?.htmlWidgetConfig) { migratedConfig = await this.updateConfigWithSvgCode(migratedConfig, INITIAL_SVG_CODE); } return migratedConfig; } save(config) { // - only persist SCADA-specific state (avoid writing stale global time context values) // - force=true applies the update synchronously so that subsequent dialog saves see the latest state this.widgetConfigService.updateConfig({ config: config.config }, true); this.configChanged$.next(config); } async updateConfigWithSvgFile(config, svgFile) { if (!svgFile || !svgFile.text) { throw new Error('Invalid SVG file provided'); } const originalSvgCode = await svgFile.text(); return this.updateConfigWithSvgCode(config, originalSvgCode); } async updateConfigWithSvgCode(config, originalSvgCode) { // Remove hardcoded dimensions to make the SVG responsive const svgCodeWithoutDimensions = await this.removeHardcodedSvgDimensions(originalSvgCode); const svgCodeWithUuids = await this.findAndAddMarkupForPotentialTextPlaceholders(svgCodeWithoutDimensions); const svgCode = this.replacePlaceholdersWithTemplateSyntax(svgCodeWithUuids); config.config = config.config || {}; config.config.htmlWidgetConfig = { config: { devMode: true, code: getWebComponentCode(svgCode), css: '', legacy: false, options: { cssEncapsulation: false, advancedSecurity: false } }, device: null, settings: {} }; this.updateMappings(config); return config; } extractTagContent(text, tagName) { const openTag = `<${tagName}>`; const closeTag = `</${tagName}>`; const openIdx = text.indexOf(openTag); const closeIdx = text.indexOf(closeTag); if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { return text.substring(openIdx + openTag.length, closeIdx); } return undefined; } async applySvgCode(code) { const config = await firstValueFrom(this.config$); const updatedConfig = await this.updateConfigWithSvgCode(config, code); this.save(updatedConfig); } async applyMappings(mappings) { try { const parsedMappings = JSON.parse(mappings); const config = await firstValueFrom(this.config$); config.config.mappings = parsedMappings; this.save(config); } catch (ex) { // eslint-disable-next-line no-console console.error('Failed to apply placeholder mapping:', ex); } } async applyScadaOutput(content) { const svgContent = this.extractTagContent(content, SVG_EXTRACT_TAG_NAME); const mappingContent = this.extractTagContent(content, MAPPING_EXTRACT_TAG_NAME); if (svgContent) { await this.applySvgCode(svgContent.trim()); } if (mappingContent) { await this.applyMappings(mappingContent.trim()); } } /** * Safely parses an SVG string containing Lit expressions, allows DOM editing, and returns the edited SVG string. * Handles escaping/unescaping Lit syntax and expressions. * * @param svg The SVG string to process. * @param editFn A function that receives the parsed SVG document for editing. * @returns The edited SVG string, or throws an exception if parsing or editing failed. */ async editSafelySvgWithLitExpressions(svg, editFn) { const litExpressions = []; const parseToDoc = (svg) => { const parser = new DOMParser(); const doc = parser.parseFromString(removeLitExpressions(escapeLitSyntax(svg), litExpressions), 'image/svg+xml'); if (doc.querySelector('parsererror')) { throw new Error('Could not parse SVG: it may contain unsupported Lit syntax.'); } return doc; }; const serializeToString = (doc) => { const serializer = new XMLSerializer(); const serializedSvg = serializer.serializeToString(doc.documentElement); return restoreLitExpressions(unescapeLitSyntax(serializedSvg), litExpressions); }; const doc = parseToDoc(svg); await editFn(doc); return serializeToString(doc); } updateMappings(config) { let keys; if (config.config.htmlWidgetConfig.config.legacy) { keys = this.extractLegacyScadaKeys(config.config.htmlWidgetConfig.config.code); } else { keys = this.extractScadaKeys(config.config.htmlWidgetConfig.config.code); } const currentMappings = config.config.mappings || {}; const newMappings = {}; for (const key of keys) { if (Object.prototype.hasOwnProperty.call(currentMappings, key)) { newMappings[key] = currentMappings[key]; } else { newMappings[key] = { assetProperty: null }; } } config.config.mappings = newMappings; } /** * Removes hardcoded width and height attributes from the root SVG element. * * @param svg The SVG string to process. * @returns The modified SVG string with width and height attributes removed from the root SVG element. */ async removeHardcodedSvgDimensions(svg) { return this.editSafelySvgWithLitExpressions(svg, async (doc) => { const svgElement = doc.documentElement; if (svgElement.tagName.toLowerCase() === 'svg') { svgElement.removeAttribute('width'); svgElement.removeAttribute('height'); } }); } /** * Processes an SVG string to identify and mark potential text placeholders within `<text>` and `<tspan>` elements. * * This method performs the following steps: * 1. Escapes Lit syntax event bindings (e.g., `@click=${...}`) to avoid XML parsing issues. * 2. Parses the SVG string into a DOM structure. * 3. For each `<text>` and `<tspan>` element: * - Adds a unique `id` attribute if not already present. * - Adds the CSS class `potential-placeholder` if the element's text content does not already contain a placeholder expression (`this.c8yScadaValues?.`). * 4. Serializes the DOM back to an SVG string and restores the original Lit syntax. * * If parsing fails (e.g., due to unhandled Lit syntax), the original SVG string is returned unchanged. * * @param svg The SVG string to process. * @returns The modified SVG string with potential placeholders marked, or the original SVG if parsing fails. */ async findAndAddMarkupForPotentialTextPlaceholders(svg) { return this.editSafelySvgWithLitExpressions(svg, async (doc) => { // Handle both <text> and <tspan> elements const nodes = doc.querySelectorAll('text, tspan'); nodes.forEach(node => { // exclude nodes which has child nodes if (node.children.length > 0) { return; } // exclude nodes which already contain expressions const nodeTextContent = node.textContent || ''; if (nodeTextContent.includes('__LIT_EXPR_')) { return; } // add id attribute with random UUID if not present if (!node.hasAttribute('id')) { node.setAttribute('id', crypto.randomUUID()); } // add class 'potential-placeholder' node.classList.add('potential-placeholder'); }); }); } /** * Replaces `{{ key }}` placeholders with new template syntax `${this.c8yScadaValues?.key}`. * * @param svgCode The SVG code string with placeholders. * @returns The SVG code string with new template syntax. */ replacePlaceholdersWithTemplateSyntax(svgCode) { return svgCode.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => `\${this.c8yScadaValues?.${key}}`); } /** * Extracts placeholder keys used in the template syntax (`${this.c8yScadaValues?.key}`) * from the provided SVG code string. * * @param svgCode The SVG code string containing template syntax placeholders. * @returns Array of unique placeholder keys. */ extractScadaKeys(svgCode) { const curlyBracesRegex = /\$\{([^}]*)\}/g; const scadaValueRegex = /this\.c8yScadaValues\??\.([a-zA-Z0-9_]+)/g; const keys = new Set(); let curlyBracesMatch; while ((curlyBracesMatch = curlyBracesRegex.exec(svgCode)) !== null) { const curlyBracesExpression = curlyBracesMatch[1]; let scadaValueMatch; while ((scadaValueMatch = scadaValueRegex.exec(curlyBracesExpression)) !== null) { keys.add(scadaValueMatch[1]); } } return Array.from(keys); } /** * Extracts placeholder keys used in the legacy template syntax (`{{ key }}`) * from the provided SVG code string. * * @param svgCode The SVG code string containing legacy template syntax placeholders. * @returns Array of unique placeholder keys. */ extractLegacyScadaKeys(svgCode) { const legacyPlaceholderRegex = /\{\{\s*(\w+)\s*\}\}/g; const keys = new Set(); let match; while ((match = legacyPlaceholderRegex.exec(svgCode)) !== null) { keys.add(match[1]); } return Array.from(keys); } async migrateConfigIfNeeded(config) { const hasNewConfig = !!config?.config?.mappings && !!config?.config?.htmlWidgetConfig; const hasLegacyConfig = !!config?.binaryId && !!config?.mapping && !!config?.deviceIds; const migrationNeeded = hasLegacyConfig && !hasNewConfig; config = this.migrateGlobalContextConfig(config); if (!migrationNeeded) { return config; } return await this.migrateFromLegacyBinary(config); } migrateGlobalContextConfig(config) { // Always migrate global context config and set displayMode to 'dashboard' if no other value was explicitly used: const hadExplicitDisplayMode = typeof config?.displayMode === 'string' && Object.values(GLOBAL_CONTEXT_DISPLAY_MODE).includes(config.displayMode); const migratedGlobalContextConfig = this.widgetConfigMigration.migrateWidgetConfig(config); config = merge(config, migratedGlobalContextConfig); if (!hadExplicitDisplayMode) { config.displayMode = GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD; } return config; } async migrateFromLegacyBinary(config) { const binaryId = config.binaryId; const binary = await this.inventoryBinary.download(binaryId); const svgCode = await binary.text(); const mappings = {}; for (const key of Object.keys(config.mapping)) { mappings[key] = { assetProperty: await this.migrateProperty(config.mapping[key], config.deviceIds[key]) }; } return { ...config, config: { htmlWidgetConfig: { config: { code: svgCode, css: '', legacy: true, devMode: false, options: { cssEncapsulation: false, // must be `false`, otherwise the legacy widgets won't work correctly advancedSecurity: false } }, device: null, settings: {} }, mappings, svgDisplaySettings: { displayOption: 'contain', horizontalAlignment: defaultAlignment, verticalAlignment: defaultAlignment } } }; } async migrateProperty(legacyProperty, assetId) { const isFieldbus = (prop) => prop['fieldbus'] === true; const isComputed = (prop) => prop['computed'] === true; const isRegular = (prop) => !isFieldbus(prop) && !isComputed(prop); const asset = (await this.inventory.detail(assetId)).data; if (isFieldbus(legacyProperty)) { return this.fieldbusService.migrateFieldbusProperty(legacyProperty, asset); } if (isComputed(legacyProperty)) { const computedPropertyName = legacyProperty.id.replace(/^c8ySchema!!/, ''); const computedProperty = await this.computedPropertiesService.getByName(computedPropertyName); return { ...computedProperty.prop, instanceId: legacyProperty.config._id, active: true, temporary: true, contextAssetId: assetId, config: legacyProperty.config, asset: { id: asset.id, name: asset.name } }; } if (isRegular(legacyProperty)) { const buildJsonSchema = (keyPath, label, type) => { if (keyPath.length === 1) { return { [keyPath[0]]: { label, type, key: keyPath[0], properties: {} } }; } // Recursively nest properties const [first, ...rest] = keyPath; return { [first]: { properties: buildJsonSchema(rest, label, type) } }; }; const assetProperty = { isEditable: true, c8y_JsonSchema: { properties: buildJsonSchema(legacyProperty.keyPath, legacyProperty.label, legacyProperty.type) }, name: legacyProperty.keyPath.join('.'), active: true, label: legacyProperty.label, type: legacyProperty.type, asset: { id: asset.id, name: asset.name } }; return assetProperty; } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); class ScadaAiChatToolDetailsComponent { constructor() { this.tool = input.required(...(ngDevMode ? [{ debugName: "tool" }] : [])); this.content = computed(() => this.tool().input?.content || '', ...(ngDevMode ? [{ debugName: "content" }] : [])); this.isExecuting = computed(() => this.tool().type !== 'tool-result', ...(ngDevMode ? [{ debugName: "isExecuting" }] : [])); this.svgContent = computed(() => this.scadaWidgetConfigService.extractTagContent(this.content(), SVG_EXTRACT_TAG_NAME), ...(ngDevMode ? [{ debugName: "svgContent" }] : [])); this.mappingContent = computed(() => this.scadaWidgetConfigService.extractTagContent(this.content(), MAPPING_EXTRACT_TAG_NAME), ...(ngDevMode ? [{ debugName: "mappingContent" }] : [])); this.mappingRows = computed(() => { const mapping = this.mappingContent(); if (!mapping) return null; try { const parsed = JSON.parse(mapping); return Object.entries(parsed).map(([placeholder, value]) => { const assetProperty = value?.assetProperty; const asset = assetProperty?.asset?.name ?? ''; let property = assetProperty?.label ?? ''; if (assetProperty?.computed && assetProperty.config?.dp?.[0]?.label) { property = assetProperty.config.dp[0].label; } return { placeholder, asset, property }; }); } catch { return null; } }, ...(ngDevMode ? [{ debugName: "mappingRows" }] : [])); this.previewSvg = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.simplifyPreviewSvg(this.svgContent() || '')), ...(ngDevMode ? [{ debugName: "previewSvg" }] : [])); this.sanitizer = inject(DomSanitizer); this.scadaWidgetConfigService = inject(ScadaWidgetConfigService); } apply() { const svg = this.svgContent(); const mapping = this.mappingContent(); if (svg) { void this.scadaWidgetConfigService.applySvgCode(svg.trim()); } if (mapping) { void this.scadaWidgetConfigService.applyMappings(mapping.trim()); } } simplifyPreviewSvg(svg) { return svg .replace(/\$\{this\.c8yScadaValues\?\.(\w+)\?\.value[^}]*\}/g, '{$1}') .replace(/\$\{this\.c8yScadaValues\?\.(\w+)\?\.(\w+)[^}]*\}/g, '{$1.$2}') .replace(/\$\{this\.c8yScadaValues\?\.(\w+)[^}]*\}/g, '{$1}') .replace(/\$\{[^}]+\}/g, ''); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaAiChatToolDetailsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: ScadaAiChatToolDetailsComponent, isStandalone: true, selector: "c8y-scada-ai-chat-tool-details", inputs: { tool: { classPropertyName: "tool", publicName: "tool", isSignal: true, isRequired: true, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: "@if (svgContent()) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 320px; overflow: auto\"\n [innerHTML]=\"previewSvg()\"\n ></div>\n}\n@if (mappingRows(); as rows) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 160px; overflow: auto\"\n >\n <table class=\"table table-condensed m-b-0\">\n <thead>\n <tr>\n <th>{{ 'Placeholder' | translate }}</th>\n <th>{{ 'Asset' | translate }}</th>\n <th>{{ 'Property' | translate }}</th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.placeholder) {\n <tr>\n <td>\n <code>{{ row.placeholder }}</code>\n </td>\n <td>{{ row.asset }}</td>\n <td>{{ row.property }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n} @else if (mappingContent()) {\n <pre\n class=\"m-b-8\"\n style=\"max-height: 160px\"\n >{{ mappingContent() }}</pre\n >\n}\n@if (!isExecuting()) {\n <div class=\"d-flex j-c-end\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Apply' | translate\"\n [tooltip]=\"'Apply' | translate\"\n container=\"body\"\n (click)=\"apply()\"\n >\n <i c8yIcon=\"check\"></i>\n </button>\n </div>\n}\n", dependencies: [{ kind: "directive", type: TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaAiChatToolDetailsComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-scada-ai-chat-tool-details', host: { style: 'display: block' }, imports: [TooltipDirective, C8yTranslatePipe, IconDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (svgContent()) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 320px; overflow: auto\"\n [innerHTML]=\"previewSvg()\"\n ></div>\n}\n@if (mappingRows(); as rows) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 160px; overflow: auto\"\n >\n <table class=\"table table-condensed m-b-0\">\n <thead>\n <tr>\n <th>{{ 'Placeholder' | translate }}</th>\n <th>{{ 'Asset' | translate }}</th>\n <th>{{ 'Property' | translate }}</th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.placeholder) {\n <tr>\n <td>\n <code>{{ row.placeholder }}</code>\n </td>\n <td>{{ row.asset }}</td>\n <td>{{ row.property }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n} @else if (mappingContent()) {\n <pre\n class=\"m-b-8\"\n style=\"max-height: 160px\"\n >{{ mappingContent() }}</pre\n >\n}\n@if (!isExecuting()) {\n <div class=\"d-flex j-c-end\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Apply' | translate\"\n [tooltip]=\"'Apply' | translate\"\n container=\"body\"\n (click)=\"apply()\"\n >\n <i c8yIcon=\"check\"></i>\n </button>\n </div>\n}\n" }] }], propDecorators: { tool: [{ type: i0.Input, args: [{ isSignal: true, alias: "tool", required: true }] }] } }); class ScadaWidgetSvgSelectorComponent { constructor() { this.widgetConfigService = inject(WidgetConfigService); this.scadaConfigService = inject(ScadaWidgetConfigService); this.sectionComponent = inject(WidgetConfigSectionComponent); this.inventoryService = inject(InventoryService); this.alertService = inject(AlertService); this.aiService = inject(AIService); this.formBuilder = inject(FormBuilder); this.destroyRef = inject(DestroyRef); this.formGroup = this.formBuilder.group({ source: this.formBuilder.nonNullable.control('file'), svgCode: this.formBuilder.nonNullable.control('', []), svgDisplaySettings: this.formBuilder.group({ displayOption: this.formBuilder.nonNullable.control(defaultDisplayOption), horizontalAlignment: this.formBuilder.nonNullable.control(defaultAlignment), verticalAlignment: this.formBuilder.nonNullable.control(defaultAlignment) }) }); this.dropArea = viewChild('dropArea', ...(ngDevMode ? [{ debugName: "dropArea" }] : [])); this.placeholderText = `<svg viewBox="0 0 200 200">…</svg>`; this.displayOptions = [ { label: gettext('Full width`image fitting option`'), value: 'full-width', description: gettext(`The image is resized to fit the widget's width while preserving the aspect ratio. Overflowing area is scrollable.`) }, { label: gettext('Contain`verb, image fitting option`'), value: 'contain', description: gettext('The image is entirely displayed within the widget while preserving the aspect ratio.') } ]; this.horizontalAlignmentOptions = [ { label: gettext('left`horizontal alignme