Add Ignition SCADA JSON export (ia.shapes.svg format)

New "SCADA" button generates Ignition-compatible JSON that can be
directly pasted into Ignition Perspective views. Converts SVG elements
to Ignition's ia.shapes.svg JSON format with:
- Proper element types (group, rect, path, text, polyline, circle)
- Fill/stroke as {paint, width} objects
- Text style as {fontFamily, fontWeight, fontSize} objects
- color, state, priority, tagpaths as top-level element properties
- Correct viewBox, meta, position structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-30 22:36:47 +04:00
parent 48bb43f471
commit 2c38950cb7
2 changed files with 186 additions and 4 deletions

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js';
import { exportSVG, exportJSON, loadLayoutJSON, loadLayoutSVG } from '$lib/export.js';
import { exportSVG, exportJSON, exportIgnitionJSON, loadLayoutJSON, loadLayoutSVG } from '$lib/export.js';
import { loadPdfFile, loadPdfFromPath, pdfZoomIn, pdfZoomOut, removePdf, toggleEditBackground, restorePdf } from '$lib/pdf.js';
import { discoverProjects } from '$lib/projects.js';
import { onMount } from 'svelte';
@ -125,7 +125,8 @@
<!-- Action bar — always visible -->
<div class="action-bar">
<button onclick={exportSVG} title="Export SVG">SVG</button>
<button onclick={exportJSON} title="Export JSON">JSON</button>
<button onclick={exportJSON} title="Export layout JSON">JSON</button>
<button onclick={exportIgnitionJSON} title="Export Ignition SCADA JSON">SCADA</button>
<button onclick={() => importFileEl.click()} title="Import layout file">Import</button>
<button class="btn-danger" onclick={clearCanvas} title="Clear canvas">Clear</button>
<input bind:this={importFileEl} type="file" accept=".json,.svg" style="display:none" onchange={onImportFile}>

View File

@ -164,7 +164,7 @@ function serializeChildren(parent: Element): string {
.join('\n ');
}
export async function exportSVG() {
async function buildSvgString(): Promise<string> {
const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
`<svg width="${layout.canvasW}" height="${layout.canvasH}" viewBox="0 0 ${layout.canvasW} ${layout.canvasH}" version="1.1"`,
@ -355,8 +355,189 @@ export async function exportSVG() {
lines.push(` <!-- LAYOUT_DATA:${JSON.stringify(layoutData)}:END_LAYOUT_DATA -->`);
lines.push('</svg>');
return lines.join('\n');
}
export async function exportSVG() {
const svgStr = await buildSvgString();
const mcmName = layout.currentMcm || 'export';
downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), `${mcmName}_Detailed_View.svg`);
downloadBlob(new Blob([svgStr], { type: 'image/svg+xml' }), `${mcmName}_Detailed_View.svg`);
}
/** Convert SVG element to Ignition JSON format */
function svgElementToIgnition(el: Element): Record<string, any> | null {
const tag = el.tagName.toLowerCase();
if (tag === 'defs' || tag === 'sodipodi:namedview') return null;
const obj: Record<string, any> = {};
if (tag === 'g') {
obj.type = 'group';
obj.name = el.getAttribute('id') || el.getAttribute('inkscape:label') || 'group';
if (el.getAttribute('id')) obj.id = el.getAttribute('id');
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
// Convert children
const children: Record<string, any>[] = [];
for (const child of Array.from(el.children)) {
const c = svgElementToIgnition(child);
if (c) children.push(c);
}
if (children.length) obj.elements = children;
// Ignition metadata from data-* attributes
if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color');
if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state');
if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority');
if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')];
} else if (tag === 'rect') {
obj.type = 'rect';
obj.name = el.getAttribute('id') || 'rect';
if (el.getAttribute('id')) obj.id = el.getAttribute('id');
for (const attr of ['x', 'y', 'width', 'height', 'rx', 'ry']) {
if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr);
}
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
addFillStroke(obj, el);
// Ignition metadata
if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color');
if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state');
if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority');
if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')];
} else if (tag === 'path') {
obj.type = 'path';
obj.name = el.getAttribute('id') || 'path';
if (el.getAttribute('id')) obj.id = el.getAttribute('id');
obj.d = el.getAttribute('d') || '';
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
addFillStroke(obj, el);
if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color');
if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state');
if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority');
if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')];
} else if (tag === 'text') {
obj.type = 'text';
obj.name = el.getAttribute('id') || 'text';
obj.text = el.textContent || '';
for (const attr of ['x', 'y']) {
if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr);
}
if (el.getAttribute('text-anchor')) obj.textAnchor = el.getAttribute('text-anchor');
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
// Parse inline style to Ignition style object
const styleStr = el.getAttribute('style');
if (styleStr) {
const styleObj: Record<string, string> = {};
let fillColor = '#000000';
for (const part of styleStr.split(';')) {
const [k, v] = part.split(':').map(s => s.trim());
if (k === 'font-family') styleObj.fontFamily = v;
else if (k === 'font-weight') styleObj.fontWeight = v;
else if (k === 'font-size') styleObj.fontSize = v;
else if (k === 'fill') fillColor = v;
}
if (Object.keys(styleObj).length) obj.style = styleObj;
obj.fill = { paint: fillColor };
} else {
if (el.getAttribute('fill')) obj.fill = { paint: el.getAttribute('fill') };
if (el.getAttribute('font-size')) obj.fontSize = el.getAttribute('font-size');
}
} else if (tag === 'polyline') {
obj.type = 'polyline';
obj.name = el.getAttribute('id') || 'polyline';
if (el.getAttribute('points')) obj.points = el.getAttribute('points');
addFillStroke(obj, el);
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
} else if (tag === 'circle') {
obj.type = 'circle';
obj.name = el.getAttribute('id') || 'circle';
for (const attr of ['cx', 'cy', 'r']) {
if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr);
}
addFillStroke(obj, el);
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
} else if (tag === 'polygon') {
obj.type = 'polygon';
obj.name = el.getAttribute('id') || 'polygon';
if (el.getAttribute('points')) obj.points = el.getAttribute('points');
addFillStroke(obj, el);
if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform');
} else {
return null; // skip unknown elements
}
return obj;
}
/** Extract fill/stroke from SVG element (handles both attributes and style) */
function addFillStroke(obj: Record<string, any>, el: Element) {
const styleStr = el.getAttribute('style');
if (styleStr) {
const styles: Record<string, string> = {};
for (const part of styleStr.split(';')) {
const [k, v] = part.split(':').map(s => s.trim());
if (k && v) styles[k] = v;
}
const fill: Record<string, string> = {};
if (styles.fill) fill.paint = styles.fill;
else if (el.getAttribute('fill')) fill.paint = el.getAttribute('fill')!;
if (styles['fill-opacity']) fill.opacity = styles['fill-opacity'];
if (fill.paint) obj.fill = fill;
const stroke: Record<string, string> = {};
if (styles.stroke) stroke.paint = styles.stroke;
if (styles['stroke-width']) stroke.width = styles['stroke-width'];
if (styles['stroke-dasharray']) stroke.dasharray = styles['stroke-dasharray'];
if (styles['stroke-opacity']) stroke.opacity = styles['stroke-opacity'];
if (stroke.paint) obj.stroke = stroke;
} else {
if (el.getAttribute('fill')) {
const fill: Record<string, string> = { paint: el.getAttribute('fill')! };
if (el.getAttribute('fill-opacity')) fill.opacity = el.getAttribute('fill-opacity')!;
obj.fill = fill;
}
if (el.getAttribute('stroke')) {
const stroke: Record<string, string> = { paint: el.getAttribute('stroke')! };
if (el.getAttribute('stroke-width')) stroke.width = el.getAttribute('stroke-width')!;
obj.stroke = stroke;
}
}
// fill="none" → paint: "transparent"
if (obj.fill?.paint === 'none') obj.fill.paint = 'transparent';
if (obj.stroke?.paint === 'none') obj.stroke.paint = 'transparent';
}
/** Export as Ignition SCADA JSON (ia.shapes.svg format) */
export async function exportIgnitionJSON() {
const svgStr = await buildSvgString();
const doc = new DOMParser().parseFromString(svgStr, 'image/svg+xml');
const svgEl = doc.documentElement;
const elements: Record<string, any>[] = [];
for (const child of Array.from(svgEl.children)) {
// Skip comments
if (child.nodeType === 8) continue;
const converted = svgElementToIgnition(child);
if (converted) elements.push(converted);
}
const mcmName = layout.currentMcm || 'export';
const ignitionData = [{
type: 'ia.shapes.svg',
version: 0,
props: {
viewBox: svgEl.getAttribute('viewBox') || `0 0 ${layout.canvasW} ${layout.canvasH}`,
elements,
},
meta: { name: `${mcmName}_Detailed_View` },
position: { width: 1, height: 1 },
custom: {},
}];
downloadBlob(
new Blob([JSON.stringify(ignitionData, null, 2)], { type: 'application/json' }),
`${mcmName}_Detailed_View.json`
);
}
/** Emit EPC symbol — polyline + icon + right box, wrapped in <g> with id/label */