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:
parent
48bb43f471
commit
2c38950cb7
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { layout } from '$lib/stores/layout.svelte.js';
|
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 { loadPdfFile, loadPdfFromPath, pdfZoomIn, pdfZoomOut, removePdf, toggleEditBackground, restorePdf } from '$lib/pdf.js';
|
||||||
import { discoverProjects } from '$lib/projects.js';
|
import { discoverProjects } from '$lib/projects.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@ -125,7 +125,8 @@
|
|||||||
<!-- Action bar — always visible -->
|
<!-- Action bar — always visible -->
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<button onclick={exportSVG} title="Export SVG">SVG</button>
|
<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 onclick={() => importFileEl.click()} title="Import layout file">Import</button>
|
||||||
<button class="btn-danger" onclick={clearCanvas} title="Clear canvas">Clear</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}>
|
<input bind:this={importFileEl} type="file" accept=".json,.svg" style="display:none" onchange={onImportFile}>
|
||||||
|
|||||||
@ -164,7 +164,7 @@ function serializeChildren(parent: Element): string {
|
|||||||
.join('\n ');
|
.join('\n ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportSVG() {
|
async function buildSvgString(): Promise<string> {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
'<?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"`,
|
`<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(` <!-- LAYOUT_DATA:${JSON.stringify(layoutData)}:END_LAYOUT_DATA -->`);
|
||||||
|
|
||||||
lines.push('</svg>');
|
lines.push('</svg>');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSVG() {
|
||||||
|
const svgStr = await buildSvgString();
|
||||||
const mcmName = layout.currentMcm || 'export';
|
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 */
|
/** Emit EPC symbol — polyline + icon + right box, wrapped in <g> with id/label */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user