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);