From 0f50ab27aec4abc4e72eba0901b1487068412124 Mon Sep 17 00:00:00 2001 From: igurielidze Date: Wed, 1 Apr 2026 01:02:39 +0400 Subject: [PATCH] Add full Ignition Perspective view.json generator with bindings, scripts, and controls Extracts all CDW5_SCADA boilerplate (zoom controls, markdown tooltip, pan/wheel events, element color/state/priority tag bindings, display filters, Start/Stop special-case bindings) into a new ignition-view.ts module. deployToIgnition() now produces the complete view.json instead of a minimal skeleton. Also adds ignitionViewPath to the store/toolbar and routes it through the deploy endpoint so views land under the correct sub-folder (e.g. DetailedView/MCM09). Co-Authored-By: Claude Opus 4.6 (1M context) --- svelte-app/src/components/Toolbar.svelte | 6 +- svelte-app/src/lib/export.ts | 26 +- svelte-app/src/lib/ignition-view.ts | 661 +++++++++++++++++++++ svelte-app/src/lib/stores/layout.svelte.ts | 3 +- svelte-app/vite.config.ts | 5 +- 5 files changed, 677 insertions(+), 24 deletions(-) create mode 100644 svelte-app/src/lib/ignition-view.ts diff --git a/svelte-app/src/components/Toolbar.svelte b/svelte-app/src/components/Toolbar.svelte index e982411..3aec4ee 100644 --- a/svelte-app/src/components/Toolbar.svelte +++ b/svelte-app/src/components/Toolbar.svelte @@ -207,7 +207,11 @@
- + +
+
+ +
diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index 6f82fde..2ba97a0 100644 --- a/svelte-app/src/lib/export.ts +++ b/svelte-app/src/lib/export.ts @@ -2,6 +2,7 @@ import { layout } from './stores/layout.svelte.js'; import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceType, isExtendoType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE, getCurveGeometry } from './symbols.js'; import { serializeSymbol, deserializeSymbol } from './serialization.js'; import { parseConveyanceLabel } from './label-utils.js'; +import { buildIgnitionViewJson } from './ignition-view.js'; import type { PlacedSymbol } from './types.js'; /** Emit conveyance label text inside a — inherits outer transform from group */ @@ -556,27 +557,12 @@ export async function exportIgnitionJSON() { /** Deploy directly to Ignition project directory */ export async function deployToIgnition() { const component = await buildIgnitionComponent(); - const projectName = layout.ignitionProject || 'Testing_Project'; + const projectName = layout.ignitionProject || 'CDW5_SCADA'; const viewName = layout.ignitionViewName || layout.currentMcm || 'MCM01'; + const viewPath = layout.ignitionViewPath || 'DetailedView'; - const viewJson = JSON.stringify({ - custom: {}, - params: {}, - props: { - defaultSize: { height: layout.canvasH, width: layout.canvasW }, - }, - root: { - children: [{ - meta: { name: viewName }, - position: { width: 1, height: 1 }, - props: component.props, - type: 'ia.shapes.svg', - }], - meta: { name: 'root' }, - props: { mode: 'percent' }, - type: 'ia.container.coord', - }, - }, null, 2); + const viewData = buildIgnitionViewJson(viewName, component); + const viewJson = JSON.stringify(viewData, null, 2); // Compute SHA-256 signature of view.json for Ignition resource integrity const viewBytes = new TextEncoder().encode(viewJson); @@ -603,7 +589,7 @@ export async function deployToIgnition() { const resp = await fetch('/api/deploy-ignition', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectName, viewName, viewJson, resourceJson }), + body: JSON.stringify({ projectName, viewName, viewPath, viewJson, resourceJson }), }); const result = await resp.json(); if (result.ok) { diff --git a/svelte-app/src/lib/ignition-view.ts b/svelte-app/src/lib/ignition-view.ts new file mode 100644 index 0000000..d1ee605 --- /dev/null +++ b/svelte-app/src/lib/ignition-view.ts @@ -0,0 +1,661 @@ +/** + * Ignition Perspective view.json generator. + * + * Produces the full CDW5_SCADA-compatible view.json with all bindings, + * scripts, events, and controls — matching the structure captured in + * ignition-boilerplate.json. + */ + +// --------------------------------------------------------------------------- +// Binding-template factories +// --------------------------------------------------------------------------- + +const COLOR_MAPPINGS = [ + { input: 0, output: '#808080' }, + { input: 20, output: '#D3D3D3' }, + { input: 1, output: '#008000' }, + { input: 11, output: '#00FF00' }, + { input: 21, output: '#CCFFCC' }, + { input: 31, output: '#556B2F' }, + { input: 2, output: '#8B0000' }, + { input: 12, output: '#FF0000' }, + { input: 22, output: '#FF7276' }, + { input: 32, output: '#B45C5C' }, + { input: 3, output: '#00008B' }, + { input: 13, output: '#0000FF' }, + { input: 23, output: '#87CEFA' }, + { input: 33, output: '#4682B4' }, + { input: 4, output: '#8B8000' }, + { input: 14, output: '#FFF700' }, + { input: 24, output: '#FFFFC5' }, + { input: 34, output: '#FFD700' }, + { input: 15, output: '#FFFFFF' }, + { input: 16, output: '#AC5F00' }, + { input: 17, output: '#FF8C00' }, +]; + +const STATE_MAPPINGS = [ + { input: 0, output: 'Off' }, + { input: 1, output: 'Disabled' }, + { input: 2, output: 'Enabled' }, + { input: 3, output: 'Starting' }, + { input: 4, output: 'Running' }, + { input: 5, output: 'Stopped' }, + { input: 6, output: 'Stopped (Auto Restart)' }, + { input: 7, output: 'Disconnected' }, + { input: 10, output: 'Power Saving' }, + { input: 11, output: 'Maintenance' }, + { input: 12, output: 'Maintenance Running' }, + { input: 13, output: 'Maintenance Jogging' }, + { input: 14, output: 'Local Control' }, + { input: 15, output: 'Inch And Store' }, + { input: 20, output: 'Half Full' }, + { input: 21, output: 'Full' }, + { input: 30, output: 'Extended' }, + { input: 31, output: 'Retracted' }, + { input: 32, output: 'In Motion' }, + { input: 33, output: 'Dumping' }, + { input: 34, output: 'Gate Up' }, + { input: 40, output: 'Jammed' }, + { input: 41, output: 'Encoder Fault' }, + { input: 42, output: 'Motor Fault' }, + { input: 43, output: 'Low Air Pressure' }, + { input: 44, output: 'Communication Fault' }, + { input: 45, output: 'Network Fault' }, + { input: 46, output: 'Power Branch Fault' }, + { input: 47, output: 'ZMX Sensor Fault' }, + { input: 48, output: 'STO Wire Loose' }, + { input: 49, output: 'Motion Timed Out' }, + { input: 50, output: 'Tipper Fault' }, + { input: 51, output: 'EStopped' }, + { input: 60, output: 'Normal' }, + { input: 61, output: 'Pressed' }, +]; + +const PRIORITY_MAPPINGS = [ + { input: 0, output: 'No Active Alarms' }, + { input: 1, output: 'High' }, + { input: 2, output: 'Medium' }, + { input: 3, output: 'Low' }, + { input: 4, output: 'Diagnostic' }, +]; + +const TEXT_CONTRAST_MAPPINGS = [ + { input: '#FF0000', output: '#FFFFFF' }, + { input: '#8B0000', output: '#FFFFFF' }, + { input: '#00008B', output: '#FFFFFF' }, + { input: '#008000', output: '#FFFFFF' }, + { input: '#556B2F', output: '#FFFFFF' }, + { input: '#0000FF', output: '#FFFFFF' }, + { input: '#8B8000', output: '#FFFFFF' }, + { input: '#AC5F00', output: '#FFFFFF' }, + { input: '#FF8C00', output: '#FFFFFF' }, + { input: '#808080', output: '#FFFFFF' }, +]; + +/** Tag-based binding for Color */ +function colorBinding(n: number): Record { + return { + binding: { + config: { + fallbackDelay: 2.5, + mode: 'indirect', + references: { + '0': `{this.props.elements[${n}].tagpaths[0]}`, + fc: '{session.custom.fc}', + }, + tagPath: '[{fc}_SCADA_TAG_PROVIDER]{0}/Color', + }, + transforms: [ + { expression: 'coalesce({value},0)', type: 'expression' }, + { + fallback: '#000000', + inputType: 'scalar', + mappings: COLOR_MAPPINGS, + outputType: 'color', + type: 'map', + }, + ], + type: 'tag', + }, + }; +} + +/** Tag-based binding for State */ +function stateBinding(n: number): Record { + return { + binding: { + config: { + fallbackDelay: 2.5, + mode: 'indirect', + references: { + '0': `{this.props.elements[${n}].tagpaths[0]}`, + fc: '{session.custom.fc}', + }, + tagPath: '[{fc}_SCADA_TAG_PROVIDER]{0}/State', + }, + transforms: [ + { expression: 'coalesce({value},999)', type: 'expression' }, + { + fallback: 'Unknown', + inputType: 'scalar', + mappings: STATE_MAPPINGS, + outputType: 'scalar', + type: 'map', + }, + ], + type: 'tag', + }, + }; +} + +/** Tag-based binding for Priority */ +function priorityBinding(n: number): Record { + return { + binding: { + config: { + fallbackDelay: 2.5, + mode: 'indirect', + references: { + '0': `{this.props.elements[${n}].tagpaths[0]}`, + fc: '{session.custom.fc}', + }, + tagPath: '[{fc}_SCADA_TAG_PROVIDER]{0}/Priority', + }, + transforms: [ + { expression: 'coalesce({value},0)', type: 'expression' }, + { + fallback: 'No Active Alarms', + inputType: 'scalar', + mappings: PRIORITY_MAPPINGS, + outputType: 'scalar', + type: 'map', + }, + ], + type: 'tag', + }, + }; +} + +/** Property binding for fill paint (rect/path background) */ +function fillPaintBinding(n: number): Record { + return { + binding: { + config: { path: `this.props.elements[${n}].color` }, + type: 'property', + }, + }; +} + +/** Property binding for text fill with contrast map */ +function textFillBinding(n: number): Record { + return { + binding: { + config: { path: `this.props.elements[${n}].color` }, + transforms: [ + { + fallback: '#000000', + inputType: 'scalar', + mappings: TEXT_CONTRAST_MAPPINGS, + outputType: 'color', + type: 'map', + }, + ], + type: 'property', + }, + }; +} + +/** Expression binding for display filter (show/hide) */ +function displayFilterBinding(filterExpr: string): Record { + return { + binding: { + config: { expression: filterExpr }, + transforms: [ + { + fallback: 'block', + inputType: 'scalar', + mappings: [{ input: false, output: 'none' }], + outputType: 'scalar', + type: 'map', + }, + ], + type: 'expr', + }, + }; +} + +// --------------------------------------------------------------------------- +// Display-filter mapping +// --------------------------------------------------------------------------- + +/** + * Given a device element name, return the alarm_filter expression (if any) + * that controls its visibility. Returns null when the device should always + * be visible (conveyance / VFD / EPC / etc.). + */ +function getDisplayFilter(label: string, n: number): string | null { + if (!label) return null; + + // FIO / SIO / FIOH + if (/_FIOM\d*$/i.test(label) || /_FIOH\d*$/i.test(label) || /_SIO\d*$/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_fio}`; + } + // Photoelectric / BDS / TS sensors + if (/_TPE\d*$/i.test(label) || /_LPE\d*$/i.test(label) || /_JPE\d*$/i.test(label) || + /_FPE\d*$/i.test(label) || /_BDS\d/i.test(label) || /_TS\d/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_pes}`; + } + // Beacons + if (/_BCN\d*$/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_beacons}`; + } + // Solenoids + if (/_SOL\d*$/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_solenoids}`; + } + // Start/Stop stations (must check before generic _S*_PB) + if (/_SS\d+/i.test(label)) { + return `({this.props.elements[${n}].state} = 'ESTOP Was Actuated') || ({this.props.elements[${n}].state} = 'Jammed') || ({this.props.elements[${n}].state} = 'Enabled')`; + } + // Jam reset push buttons + if (/_JR\d*_PB$/i.test(label)) { + return `({this.props.elements[${n}].state} = 'ESTOP Was Actuated') || ({this.props.elements[${n}].state} = 'Jammed') || ({this.props.elements[${n}].state} = 'Enabled')`; + } + // Start push buttons + if (/_S\d+_PB$/i.test(label)) { + return `({this.props.elements[${n}].state} = 'ESTOP Was Actuated') || ({this.props.elements[${n}].state} = 'Jammed') || ({this.props.elements[${n}].state} = 'Enabled')`; + } + // DPM gateways + if (/_DPM\d*$/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_gateways}`; + } + // PDP safety + if (/^PDP\d*/i.test(label)) { + return `({this.props.elements[${n}].priority} = 'High') || {session.custom.alarm_filter.show_safety}`; + } + + // VFD / EPC / conveyance — always visible + return null; +} + +// --------------------------------------------------------------------------- +// Per-element binding generation +// --------------------------------------------------------------------------- + +interface SvgElement { + name?: string; + id?: string; + type?: string; + tagpaths?: string[]; + elements?: SvgElement[]; + [key: string]: any; +} + +/** + * Generate propConfig entries for all elements that have tagpaths. + */ +function generateElementBindings(elements: SvgElement[]): Record { + const propConfig: Record = {}; + + for (let n = 0; n < elements.length; n++) { + const el = elements[n]; + if (!el.tagpaths || el.tagpaths.length === 0) continue; + + const prefix = `props.elements[${n}]`; + const elName = el.name || el.id || ''; + const isStartStop = /_SS\d+/i.test(elName); + + // Core bindings: color, state, priority + propConfig[`${prefix}.color`] = colorBinding(n); + propConfig[`${prefix}.state`] = stateBinding(n); + propConfig[`${prefix}.priority`] = priorityBinding(n); + + // Sub-element fill bindings + if (el.elements && el.elements.length > 0) { + for (let m = 0; m < el.elements.length; m++) { + const sub = el.elements[m]; + if (sub.type === 'text') { + propConfig[`${prefix}.elements[${m}].fill.paint`] = textFillBinding(n); + } else { + // rect, path, etc. — background fill + propConfig[`${prefix}.elements[${m}].fill.paint`] = fillPaintBinding(n); + } + } + } + + // Display filter + const filterExpr = getDisplayFilter(elName, n); + if (filterExpr) { + propConfig[`${prefix}.style.display`] = displayFilterBinding(filterExpr); + } + + // Start/Stop special case: add start_* and stop_* bindings + if (isStartStop) { + propConfig[`${prefix}.start_color`] = colorBinding(n); + propConfig[`${prefix}.start_state`] = stateBinding(n); + propConfig[`${prefix}.start_priority`] = priorityBinding(n); + propConfig[`${prefix}.stop_color`] = colorBinding(n); + propConfig[`${prefix}.stop_state`] = stateBinding(n); + propConfig[`${prefix}.stop_priority`] = priorityBinding(n); + } + } + + return propConfig; +} + +// --------------------------------------------------------------------------- +// Boilerplate components (extracted from ignition-boilerplate.json) +// --------------------------------------------------------------------------- + +const COORD_EVENTS = { + dom: { + onPointerMove: { + config: { + script: '\tif event.buttons > 0:\n\t\tself.view.custom.x += event.movementX/self.view.custom.scale\n\t\tself.view.custom.y += event.movementY/self.view.custom.scale', + }, + scope: 'G', + type: 'script', + }, + onWheel: { + config: { + script: '\n\ttry:\n\t\tpage = system.perspective.getPage()\n\t\tviewportWidth = float(page.props.dimensions.viewport.width)\n\t\tviewportHeight = float(page.props.dimensions.viewport.height)\n\texcept:\n\t\tviewportWidth = float(self.view.props.defaultSize.width)\n\t\tviewportHeight = float(self.view.props.defaultSize.height)\n\t\n\tmouseX = float(event.clientX)\n\tmouseY = float(event.clientY)\n\toldScale = float(self.view.custom.scale)\n\toldX = float(self.view.custom.x)\n\toldY = float(self.view.custom.y)\n\tcontainerCenterX = viewportWidth * 0.5\n\tcontainerCenterY = viewportHeight * 0.5\n\tmouseRelX = mouseX - containerCenterX\n\tmouseRelY = mouseY - containerCenterY\n\tcontainerX = (mouseRelX / oldScale) - oldX\n\tcontainerY = (mouseRelY / oldScale) - oldY\n\tzoomFactor = 1.1 if event.deltaY < 0 else 0.9090909090909091\n\tnewScale = oldScale * zoomFactor\n\tminScale = 0.5\n\tmaxScale = 10.0\n\tif newScale < minScale or newScale > maxScale:\n\t\treturn\n\tnewX = (mouseRelX / newScale) - containerX\n\tnewY = (mouseRelY / newScale) - containerY\n\tself.view.custom.scale = newScale\n\tself.view.custom.x = newX\n\tself.view.custom.y = newY', + }, + scope: 'G', + type: 'script', + }, + }, +}; + +const SVG_EVENTS = { + dom: { + onClick: { + config: { + script: "\t\n\tif(self.view.custom.key == \"error\"):\n\t\tsystem.perspective.closeDock('Docked-East')\n\telse:\n\t\tsystem.perspective.openDock('Docked-East', params={'tagProps': [str(self.view.custom.key)]})", + }, + scope: 'G', + type: 'script', + }, + onDoubleClick: { + config: { + script: '\t\n\tself.view.custom.scale = 1.0\n\tself.view.custom.x = 0.0\n\tself.view.custom.y = 0.0', + }, + scope: 'G', + type: 'script', + }, + }, +}; + +/* eslint-disable no-tabs */ +const MARKDOWN_SOURCE_SCRIPT = "\t# Full script for the transform. value comes from expression: \"key\" (no panning toggle - pan/zoom and clicks work together)\n\t# Use this inside your binding transform (then escape for JSON: newlines -> \\n, \" -> \\\", etc.)\n\t\n\tcode = \" view.value.mountPath == componentPath).value; \"\n\t\n\t# Find the SVG element\n\tcode += \"let svgElement = currentNode; \"\n\tcode += \"while(svgElement && svgElement.tagName !== 'svg'){svgElement = svgElement.querySelector('svg') || svgElement.parentNode;} \"\n\t\n\tcode += \"if(svgElement && svgElement.tagName === 'svg'){\"\n\tcode += \"svgElement.style.userSelect = 'none'; svgElement.style.webkitUserSelect = 'none'; \"\n\t\n\t# Add hover styles\n\tcode += \"const hoverStyle = document.createElement('style'); \"\n\tcode += \"hoverStyle.textContent = 'svg{cursor:default;} g[name],rect[name],path[name],circle[name],ellipse[name],polygon[name],line[name],polyline[name],text[name]{transition:all 0.2s ease;cursor:pointer;} svg.svg-panning,svg.svg-panning g[name],svg.svg-panning rect[name],svg.svg-panning path[name],svg.svg-panning circle[name],svg.svg-panning ellipse[name],svg.svg-panning polygon[name],svg.svg-panning line[name],svg.svg-panning polyline[name],svg.svg-panning text[name]{cursor:grab!important;}'; \"\n\tcode += \"svgElement.appendChild(hoverStyle); \"\n\t\n\t# Create tooltip\n\tcode += \"const tooltip = document.createElement('div'); \"\n\tcode += \"tooltip.style.cssText = 'position:fixed;color:#fff;padding:10px 14px;border-radius:6px;font-size:13px;pointer-events:none;z-index:10000;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:Arial,sans-serif;max-width:400px;word-wrap:break-word;white-space:pre-wrap;line-height:1.5;font-weight:bold;'; \"\n\tcode += \"document.body.appendChild(tooltip); \"\n\t\n\t# Get clickable elements\n\tcode += \"const clickables = svgElement.querySelectorAll('g[name],rect[name],path[name],circle[name],ellipse[name],polygon[name],line[name],polyline[name],text[name]'); \"\n\t\n\t# Variables\n\tcode += \"let activeElement = null; \"\n\tcode += \"let tooltipTimeout = null; \"\n\tcode += \"let mouseDownX = 0, mouseDownY = 0; \"\n\tcode += \"let mouseDown = false, isPanning = false; \"\n\t\n\t# Priority color function\n\tcode += \"function getPriorityColor(priority){\"\n\tcode += \"const colorMap = {'1':'#FF0000','High':'#FF0000','2':'#FFB200','Medium':'#FFB200','3':'#FFFF00','Low':'#FFFF00','4':'#4747FF','Diagnostic':'#4747FF','5':'#00CC00','No Active Alarms':'#00CC00'}; \"\n\tcode += \"return colorMap[priority] || 'rgba(0,0,0,0.9)';} \"\n\t\n\t# Clean ID function\n\tcode += \"function cleanId(id){if(!id || typeof id !== 'string') return ''; return id.replace(/^[A-Z](-\\\\d+)+-/, '');} \"\n\t# tagpaths is a JSON array on the view model, not a DOM attribute \u0432\u0402\u201d build id/name -> tagpaths[0] from nested props.elements\n\tcode += \"var __tagpathByKey=null; \"\n\tcode += \"function ensureTagpathLookup(v){if(__tagpathByKey!==null)return; __tagpathByKey=Object.create(null); \"\n\tcode += \"var seen=(typeof WeakSet!=='undefined')?new WeakSet():null;var plainSeen=[]; \"\n\tcode += \"function mark(o){if(!o||typeof o!=='object')return false; if(seen){try{if(seen.has(o))return false; seen.add(o);return true;}catch(e){}} if(plainSeen.indexOf(o)>=0)return false; plainSeen.push(o);return true;} \"\n\tcode += \"function walkElements(arr){if(!Array.isArray(arr))return; for(var i=0;i20||!o||typeof o!=='object')return; if(!mark(o))return; if(Array.isArray(o.elements))walkElements(o.elements); \"\n\tcode += \"try{ var ks=Object.keys(o); for(var k=0;k vw){ left = x - tw - 15; } \"\n\tcode += \"if(top + th > vh){ top = y - th - 15; } \"\n\tcode += \"if(left < 0) left = 5; if(top < 0) top = 5; \"\n\tcode += \"tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; } \"\n\t\n\t# Apply active style function\n\tcode += \"function applyActiveStyle(element){\"\n\tcode += \"if(activeElement){activeElement.style.filter=''; activeElement.style.cursor='pointer';} \"\n\tcode += \"if(element && element.getAttribute('name') !== 'error'){\"\n\tcode += \"activeElement = element; activeElement.style.cursor='pointer'; \"\n\tcode += \"const glowColor = window.getComputedStyle(activeElement).color; \"\n\tcode += \"activeElement.style.filter='drop-shadow(0 0 2px '+glowColor+') drop-shadow(0 0 4px '+glowColor+') drop-shadow(0 0 8px '+glowColor+')';} \"\n\tcode += \"else {activeElement = null;}} \"\n\t\n\t# Highlight element function\n\tcode += \"function highlightElement(nameValue, idValue){\"\n\tcode += \"if(typeof nameValue === 'object' && nameValue && nameValue.value){nameValue = nameValue.value;} \"\n\tcode += \"if(typeof idValue === 'object' && idValue && idValue.value){idValue = idValue.value;} \"\n\tcode += \"if(!nameValue || nameValue === 'error'){\"\n\tcode += \"if(activeElement){activeElement.style.filter=''; activeElement.style.cursor='pointer'; activeElement = null;} \"\n\tcode += \"view.custom.write('is_found', nameValue + ' FALSE'); return;} \"\n\tcode += \"let targetElement = null; \"\n\tcode += \"for(let i=0; i{ \"\n\tcode += \"let parent = el.parentNode; let isChild = false; \"\n\tcode += \"while(parent && parent !== svgElement) { \"\n\tcode += \"if(parent.hasAttribute('name')) { isChild = true; break; } \"\n\tcode += \"parent = parent.parentNode; } \"\n\tcode += \"if(isChild) return; \"\n\tcode += \"const fillOpacity = parseFloat(window.getComputedStyle(el).fillOpacity); \"\n\tcode += \"if(fillOpacity === 0 || isNaN(fillOpacity)){el.style.pointerEvents = 'stroke';} else {el.style.pointerEvents = 'auto';} \"\n\tcode += \"el.style.cursor = 'pointer'; \"\n\tcode += \"el.addEventListener('mouseenter',function(e){\"\n\tcode += \"if(isPanning){ this.style.cursor='grab'; this.style.filter=''; return; } \"\n\tcode += \"const self = this; const elName = this.getAttribute('name') || ''; \"\n\tcode += \"const sourceId = getElementSourceId(this) || elName || 'N/A'; \"\n\tcode += \"if(elName === 'error' || sourceId === 'error') { this.style.cursor = 'not-allowed'; return; } \"\n\tcode += \"this.style.cursor = 'pointer'; \"\n\tcode += \"let displayId = sourceId; \"\n\tcode += \"if(sourceId.includes('/SMC/Chute/')){let idValue = this.getAttribute('id') || ''; displayId = cleanId(idValue) || sourceId;} \"\n\tcode += \"let priorityValue, stateValue, bgColor, textColor; \"\n\tcode += \"const isSSElement = /_SS\\\\d+/.test(elName) || /_SS\\\\d+/.test(sourceId); \"\n\tcode += \"if(isSSElement){ \"\n\tcode += \"const startPriority = this.getAttribute('start_priority') || 'N/A'; \"\n\tcode += \"const stopPriority = this.getAttribute('stop_priority') || 'N/A'; \"\n\tcode += \"const startState = this.getAttribute('start_state') || 'N/A'; \"\n\tcode += \"const stopState = this.getAttribute('stop_state') || 'N/A'; \"\n\tcode += \"const stopColor = this.getAttribute('stop_color') || ''; \"\n\tcode += \"priorityValue = 'Start: ' + startPriority + ' / Stop: ' + stopPriority; \"\n\tcode += \"stateValue = 'Start: ' + startState + ' / Stop: ' + stopState; \"\n\tcode += \"bgColor = stopColor || getPriorityColor(stopPriority); \"\n\tcode += \"textColor = (bgColor === '#FFFF00' || bgColor === '#ffff00') ? '#000000' : '#ffffff'; \"\n\tcode += \"} else { \"\n\tcode += \"priorityValue = this.getAttribute('priority') || 'N/A'; \"\n\tcode += \"stateValue = this.getAttribute('state') || 'N/A'; \"\n\tcode += \"bgColor = getPriorityColor(priorityValue); \"\n\tcode += \"textColor = (bgColor === '#FFFF00') ? '#000000' : '#ffffff'; } \"\n\tcode += \"const tooltipText = 'Source ID: ' + displayId + '\\\\nPriority: ' + priorityValue + '\\\\nState: ' + stateValue; \"\n\tcode += \"if(this === activeElement){\"\n\tcode += \"tooltipTimeout = setTimeout(function(){tooltip.textContent = tooltipText; tooltip.style.background = bgColor; \"\n\tcode += \"tooltip.style.color = textColor; tooltip.style.display = 'block'; positionTooltip(e.clientX, e.clientY);}, 500); return;} \"\n\tcode += \"tooltipTimeout = setTimeout(function(){tooltip.textContent = tooltipText; tooltip.style.background = bgColor; \"\n\tcode += \"tooltip.style.color = textColor; tooltip.style.display = 'block'; positionTooltip(e.clientX, e.clientY);}, 500);},false); \"\n\tcode += \"el.addEventListener('mousemove',function(e){\"\n\tcode += \"if(tooltip.style.display === 'block'){positionTooltip(e.clientX, e.clientY);}},false); \"\n\tcode += \"el.addEventListener('mouseleave',function(){\"\n\tcode += \"if(tooltipTimeout){clearTimeout(tooltipTimeout); tooltipTimeout = null;} \"\n\tcode += \"tooltip.style.display = 'none'; \"\n\tcode += \"if(this !== activeElement){this.style.filter=''; this.style.cursor='pointer';}},false);}); \"\n\t\n\t# Track mousedown and detect panning to suppress hover effects\n\tcode += \"svgElement.addEventListener('mousedown',function(e){ mouseDown = true; mouseDownX = e.clientX; mouseDownY = e.clientY; },false); \"\n\tcode += \"document.addEventListener('mousemove',function(e){ \"\n\tcode += \"if(mouseDown){ const moveThreshold = 5; \"\n\tcode += \"if(Math.abs(e.clientX - mouseDownX) > moveThreshold || Math.abs(e.clientY - mouseDownY) > moveThreshold){ \"\n\tcode += \"if(!isPanning){ isPanning = true; svgElement.classList.add('svg-panning'); \"\n\tcode += \"if(tooltipTimeout){ clearTimeout(tooltipTimeout); tooltipTimeout = null; } tooltip.style.display = 'none'; \"\n\tcode += \"clickables.forEach(function(el){ if(el !== activeElement){ el.style.filter=''; el.style.cursor='grab'; } }); } } } },false); \"\n\tcode += \"document.addEventListener('mouseup',function(){ mouseDown = false; isPanning = false; svgElement.classList.remove('svg-panning'); \"\n\tcode += \"clickables.forEach(function(el){ el.style.cursor=''; }); \"\n\tcode += \"if(activeElement){ activeElement.style.cursor='pointer'; } },false); \"\n\t\n\t# SVG mouseup event for selection\n\tcode += \"svgElement.addEventListener('mouseup',function(event){\"\n\tcode += \"const moveThreshold = 5; \"\n\tcode += \"if(Math.abs(event.clientX - mouseDownX) > moveThreshold || Math.abs(event.clientY - mouseDownY) > moveThreshold){ return; } \"\n\tcode += \"let elNode = event.target; let target = null; \"\n\tcode += \"while(elNode && elNode !== svgElement){ \"\n\tcode += \"if(elNode.hasAttribute('name')){ target = elNode; } \"\n\tcode += \"elNode = elNode.parentNode; } \"\n\tcode += \"if(target && target.hasAttribute('name')){\"\n\tcode += \"const elName = target.getAttribute('name') || ''; \"\n\tcode += \"const nameValue = getElementSourceId(target) || elName; \"\n\tcode += \"let idValue = target.getAttribute('id') || ''; \"\n\tcode += \"idValue = cleanId(idValue); \"\n\tcode += \"if(!nameValue || nameValue.trim() === ''){view.custom.write('\" + value + \"','error'); return;} \"\n\tcode += \"view.custom.write('\" + value + \"',nameValue); \"\n\tcode += \"view.custom.write('key_1',idValue); \"\n\tcode += \"if(elName !== 'error' && nameValue !== 'error') { applyActiveStyle(target); } \"\n\tcode += \"} else { view.custom.write('\" + value + \"', 'error'); } },false);\"\n\t\n\t# Polling for external selection changes\n\tcode += \"let __lastSel = null; \"\n\tcode += \"setInterval(function(){try{\"\n\tcode += \"const sel = view.custom.read('\" + value + \"'); \"\n\tcode += \"const idv = view.custom.read('key_1'); \"\n\tcode += \"if(sel && sel !== __lastSel){__lastSel = sel; highlightElement(sel, idv);} \"\n\tcode += \"}catch(e){} }, 200);\"\n\t\n\tcode += \"}\\\"\"\n\t\n\treturn code"; +/* eslint-enable no-tabs */ + +function buildMarkdownComponent(): Record { + return { + meta: { name: 'Markdown' }, + position: { height: 0, width: 1 }, + propConfig: { + 'props.source': { + binding: { + config: { expression: '"key"' }, + transforms: [ + { + code: MARKDOWN_SOURCE_SCRIPT, + type: 'script', + }, + ], + type: 'expr', + }, + }, + }, + props: { + markdown: { escapeHtml: false }, + }, + type: 'ia.display.markdown', + }; +} + +const ZOOM_CONTROLS: Record[] = [ + { + events: { + dom: { + onClick: { + config: { script: '\tself.view.custom.scale = 1\n\tself.view.custom.x = 0\n\tself.view.custom.y = 0' }, + scope: 'G', + type: 'script', + }, + }, + }, + meta: { name: 'Reset', tooltip: { enabled: true, text: 'Reset zoom' } }, + position: { height: 0.0324, width: 0.0182 }, + props: { + path: 'material/zoom_out_map', + style: { color: '#555555', cursor: 'pointer', filter: 'invert(100%)', 'mix-blend-mode': 'difference' }, + }, + type: 'ia.display.icon', + }, + { + events: { + dom: { + onClick: { + config: { script: '\tself.view.custom.scale /= 1.1' }, + scope: 'G', + type: 'script', + }, + }, + }, + meta: { name: 'Out', tooltip: { enabled: true, text: 'Zoom out' } }, + position: { height: 0.0324, width: 0.0182, x: 0.0183 }, + props: { + path: 'material/zoom_out', + style: { color: '#555555', cursor: 'pointer', filter: 'invert(100%)', 'mix-blend-mode': 'difference' }, + }, + type: 'ia.display.icon', + }, + { + events: { + dom: { + onClick: { + config: { script: '\tself.view.custom.scale *= 1.1' }, + scope: 'G', + type: 'script', + }, + }, + }, + meta: { name: 'In', tooltip: { enabled: true, text: 'Zoom in' } }, + position: { height: 0.0324, width: 0.0182, x: 0.0366 }, + props: { + path: 'material/zoom_in', + style: { color: '#555555', cursor: 'pointer', filter: 'invert(100%)', 'mix-blend-mode': 'difference' }, + }, + type: 'ia.display.icon', + }, + { + meta: { name: 'Zoom' }, + position: { height: 0.0324, width: 0.0469, x: 0.0549, y: 0.0028 }, + propConfig: { + 'props.placeholder.text': { + binding: { + config: { expression: 'round({view.custom.scale}*100)+"%"' }, + type: 'expr', + }, + }, + 'props.value': { + binding: { + config: { bidirectional: true, path: 'view.custom.scale' }, + type: 'property', + }, + }, + }, + props: { + dropdownOptionStyle: { borderStyle: 'none', fontSize: '10px', textAlign: 'center' }, + options: [ + { label: '25%', value: 0.25 }, + { label: '50%', value: 0.5 }, + { label: '100%', value: 1 }, + { label: '150%', value: 1.5 }, + { label: '200%', value: 2 }, + { label: '500%', value: 5 }, + ], + placeholder: {}, + search: { enabled: false }, + style: { + backgroundColor: '#FFFFFF00', + borderStyle: 'none', + color: '#555555', + cursor: 'pointer', + filter: 'invert(100%)', + fontSize: '1.5vmin', + 'mix-blend-mode': 'difference', + 'user-select': 'none', + }, + textAlign: 'center', + }, + type: 'ia.input.dropdown', + }, +]; + +// --------------------------------------------------------------------------- +// Main builder +// --------------------------------------------------------------------------- + +/** + * Build the complete Ignition Perspective view.json object. + * + * @param mcmName e.g. "MCM09" + * @param svgComponent The ia.shapes.svg component object (with .props.elements) + */ +export function buildIgnitionViewJson( + mcmName: string, + svgComponent: Record, +): Record { + const elements: SvgElement[] = svgComponent.props?.elements || []; + const elementBindings = generateElementBindings(elements); + + return { + custom: { + clicked_x: 0, + clicked_y: 0, + is_found: '', + key: '', + key_1: '', + scale: 1, + x: 0, + y: 0, + }, + params: { + controls: { click: true, in: true, out: true, pan: true, reset: true, zoom: true }, + mcm: '', + panning: false, + selectDevice: '', + viewParams: { highlightTagPath: 'value' }, + viewPath: `Detailed_Views/MCM-Views/${mcmName}`, + }, + propConfig: { + 'custom.is_found': { + onChange: { + enabled: null, + script: + "\t# Ignore empty / no active search\n\tif not currentValue.value:\n\t\treturn\n\t\n\tdevice = self.params. selectDevice or \"\"\n\tif not device:\n\t\treturn\n\t\n\t# Making sure this result belongs to the current device attempt\n\t# (since is_found contains \" TRUE/FALSE\")\n\tif device not in str(currentValue.value):\n\t\treturn\n\t\n\tconfig.mcm_router.handleIsFound(self, currentValue.value)", + }, + }, + 'custom.scale': { + binding: { + config: { + expression: '({page.props.dimensions.viewport.width}) / 1920', + }, + enabled: false, + transforms: [ + { + code: "\t#this script is to see the view in the DESIGNER!!!\n\tif value == 0.0:\n\t\treturn 1\n\treturn value ", + type: 'script', + }, + ], + type: 'expr', + }, + persistent: true, + }, + 'custom.x': { persistent: true }, + 'custom.y': { persistent: true }, + 'params.mcm': { paramDirection: 'input', persistent: true }, + 'params.selectDevice': { paramDirection: 'input', persistent: true }, + }, + props: { + defaultSize: { height: 1028, width: 1850 }, + }, + root: { + children: [ + // CoordinateContainer with SVG + Markdown + { + children: [ + // SVG component with events and element bindings + { + events: SVG_EVENTS, + meta: { name: mcmName }, + position: { height: 1.0506, width: 1.0378 }, + propConfig: elementBindings, + props: svgComponent.props, + type: 'ia.shapes.svg', + }, + // Markdown tooltip component + buildMarkdownComponent(), + ], + events: COORD_EVENTS, + meta: { name: 'CoordinateContainer' }, + position: { height: 1, width: 1 }, + propConfig: { + 'props.style.transform': { + binding: { + config: { + expression: + '"scale("+{view.custom.scale}+") translate("+{view.custom.x}+"px,"+{view.custom.y}+"px)"', + }, + type: 'expr', + }, + }, + }, + props: { + mode: 'percent', + style: { + WebkitUserSelect: 'none', + overflow: 'visible', + paddingLeft: 'auto', + paddingRight: 'auto', + transition: 'transform 100ms linear', + userSelect: 'none', + }, + }, + type: 'ia.container.coord', + }, + // Zoom controls (Reset, Out, In, Dropdown) + ...ZOOM_CONTROLS, + ], + meta: { name: 'root' }, + position: { x: 0, y: 0 }, + props: { + mode: 'percent', + style: { + overflow: 'hidden', + overflowX: 'hidden', + overflowY: 'hidden', + padding: '0 auto', + }, + }, + scripts: { + customMethods: [], + extensionFunctions: null, + messageHandlers: [ + { + messageType: 'focusDevice', + pageScope: false, + script: + '\tself.view.custom.x = payload["x"]\n\tself.view.custom.y = payload["y"]\n\tself.view.custom.scale = payload["scale"]', + sessionScope: true, + viewScope: false, + }, + ], + }, + type: 'ia.container.coord', + }, + }; +} diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts index da57e86..e671904 100644 --- a/svelte-app/src/lib/stores/layout.svelte.ts +++ b/svelte-app/src/lib/stores/layout.svelte.ts @@ -26,8 +26,9 @@ class LayoutStore { currentMcm = $state(''); // Ignition export settings - ignitionProject = $state('Testing_Project'); + ignitionProject = $state('CDW5_SCADA'); ignitionViewName = $state(''); + ignitionViewPath = $state('DetailedView'); // PDF state pdfScale = $state(1.0); diff --git a/svelte-app/vite.config.ts b/svelte-app/vite.config.ts index 5450669..83499cb 100644 --- a/svelte-app/vite.config.ts +++ b/svelte-app/vite.config.ts @@ -263,9 +263,10 @@ export default defineConfig({ req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { - const { projectName, viewName, viewJson, resourceJson } = JSON.parse(body); + const { projectName, viewName, viewPath, viewJson, resourceJson } = JSON.parse(body); const ignitionBase = 'C:/Program Files/Inductive Automation/Ignition/data/projects'; - const viewDir = path.join(ignitionBase, projectName, 'com.inductiveautomation.perspective/views', viewName); + const viewSubPath = viewPath ? `${viewPath}/${viewName}` : viewName; + const viewDir = path.join(ignitionBase, projectName, 'com.inductiveautomation.perspective/views', viewSubPath); fs.mkdirSync(viewDir, { recursive: true }); fs.writeFileSync(path.join(viewDir, 'view.json'), viewJson); fs.writeFileSync(path.join(viewDir, 'resource.json'), resourceJson);