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) <noreply@anthropic.com>
730 lines
32 KiB
TypeScript
730 lines
32 KiB
TypeScript
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 <g> — inherits outer transform from group */
|
|
function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) {
|
|
if (!sym.label) return;
|
|
const { lines: textLines } = parseConveyanceLabel(sym.label);
|
|
|
|
let labelCx: number, labelCy: number, availH: number;
|
|
let textRotDeg = 0; // additional rotation for the text
|
|
|
|
if (isCurvedType(sym.symbolId)) {
|
|
const angle = sym.curveAngle || 90;
|
|
const { arcCx, arcCy, outerR, innerR, bandW } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
|
const midR = (outerR + innerR) / 2;
|
|
const midAngleRad = ((angle / 2) * Math.PI) / 180;
|
|
labelCx = arcCx + midR * Math.cos(midAngleRad);
|
|
labelCy = arcCy - midR * Math.sin(midAngleRad);
|
|
availH = bandW - 4;
|
|
// Tangent rotation (same as canvas renderer)
|
|
let textRotRad = -midAngleRad + Math.PI / 2;
|
|
// Check readability with outer rotation
|
|
const symRotRad = ((sym.rotation || 0) * Math.PI) / 180;
|
|
let worldAngle = (textRotRad + symRotRad) % (2 * Math.PI);
|
|
if (worldAngle < 0) worldAngle += 2 * Math.PI;
|
|
if (worldAngle > Math.PI / 2 && worldAngle < Math.PI * 3 / 2) textRotRad += Math.PI;
|
|
// Mirror: label follows the mirrored shape naturally
|
|
textRotDeg = (textRotRad * 180) / Math.PI;
|
|
} else if (isSpurType(sym.symbolId)) {
|
|
const w2 = sym.w2 ?? sym.w;
|
|
const fontSize = LABEL_CONFIG.fontSize;
|
|
const th = fontSize * textLines.length;
|
|
const optCy = Math.min(sym.h - th / 2 - 1, sym.h * 0.55);
|
|
const textTop = optCy - th / 2;
|
|
const rightEdge = w2 + (Math.max(0, textTop) / sym.h) * (sym.w - w2);
|
|
// Estimate text width (~8px per char at 14px bold)
|
|
const estTextW = Math.max(...textLines.map(l => l.length * 8));
|
|
labelCx = sym.x + Math.max(estTextW / 2, rightEdge - estTextW / 2);
|
|
labelCy = sym.y + optCy;
|
|
availH = sym.h - 4;
|
|
} else if (isInductionType(sym.symbolId)) {
|
|
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
|
|
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
|
|
labelCx = sym.x + (INDUCTION_CONFIG.headWidth + sym.w) / 2;
|
|
labelCy = (stripTopY + stripBottomY) / 2;
|
|
availH = stripBottomY - stripTopY - 4;
|
|
} else {
|
|
labelCx = sym.x + sym.w / 2;
|
|
labelCy = sym.y + sym.h / 2;
|
|
availH = sym.h - 4;
|
|
}
|
|
|
|
// For non-curved: check readability and flip if needed
|
|
let needsMirrorFix = false;
|
|
if (!isCurvedType(sym.symbolId)) {
|
|
const rot = ((sym.rotation || 0) % 360 + 360) % 360;
|
|
const effectiveAngle = sym.mirrored ? ((360 - rot) % 360) : rot;
|
|
if (effectiveAngle > 90 && effectiveAngle < 270) textRotDeg = 180;
|
|
needsMirrorFix = !!sym.mirrored;
|
|
}
|
|
|
|
const fontSize = Math.min(LABEL_CONFIG.fontSize, availH / textLines.length);
|
|
if (fontSize < LABEL_CONFIG.minFontSize) return;
|
|
const lineH = fontSize;
|
|
// Build transform: counter-mirror then rotate for readability
|
|
let transformParts: string[] = [];
|
|
if (needsMirrorFix) transformParts.push(`translate(${labelCx},${labelCy}) scale(-1,1) translate(${-labelCx},${-labelCy})`);
|
|
if (textRotDeg) transformParts.push(`rotate(${textRotDeg.toFixed(1)},${labelCx},${labelCy})`);
|
|
const rotAttr = transformParts.length ? ` transform="${transformParts.join(' ')}"` : '';
|
|
for (let i = 0; i < textLines.length; i++) {
|
|
const dy = -(textLines.length - 1) * lineH / 2 + i * lineH;
|
|
const y = labelCy + dy + fontSize * LABEL_CONFIG.baselineOffset;
|
|
lines.push(` <text x="${labelCx}" y="${y}" text-anchor="middle" style="font-family:${LABEL_CONFIG.fontFamily};font-weight:bold;font-size:${fontSize}px;fill:${LABEL_CONFIG.color}"${rotAttr}>${textLines[i]}</text>`);
|
|
}
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, filename: string) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function exportJSON() {
|
|
const data = {
|
|
symbols: layout.symbols.map(s => serializeSymbol(s)),
|
|
gridSize: layout.gridSize,
|
|
minSpacing: layout.minSpacing,
|
|
canvasW: layout.canvasW,
|
|
canvasH: layout.canvasH,
|
|
};
|
|
const mcmName = layout.currentMcm || 'export';
|
|
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `${mcmName}_layout.json`);
|
|
}
|
|
|
|
/** Declarative tag-path rules: order matters (longer/more-specific patterns first).
|
|
* Each rule maps a label suffix pattern to the Ignition folder path. */
|
|
const TAG_PATH_RULES: Array<{ pattern: RegExp; path: string }> = [
|
|
// VFD / EPC
|
|
{ pattern: /_VFD\d*$/i, path: 'VFD/APF' },
|
|
{ pattern: /_EPC\d*$/i, path: 'VFD/APF' },
|
|
// Sensors
|
|
{ pattern: /_TPE\d*$/i, path: 'Sensor/Tracking' },
|
|
{ pattern: /_LPE\d*$/i, path: 'Sensor/Long_Range' },
|
|
{ pattern: /_JPE\d*$/i, path: 'Sensor/Jam' },
|
|
{ pattern: /_FPE\d*$/i, path: 'Sensor/Full' },
|
|
{ pattern: /_PS\d*$/i, path: 'Sensor/Pressure' },
|
|
{ pattern: /_BDS\d+_[RS]$/i, path: 'Sensor/Tracking' },
|
|
{ pattern: /_TS\d+_[RS]$/i, path: 'Sensor/Tracking' },
|
|
// Network nodes
|
|
{ pattern: /_FIOM\d*$/i, path: 'Network_Node/FIO' },
|
|
{ pattern: /_FIOH\d*$/i, path: 'Network_Node/HUB' },
|
|
{ pattern: /_SIO\d*$/i, path: 'Network_Node/SIO' },
|
|
{ pattern: /_DPM\d*$/i, path: 'Network_Node/DPM' },
|
|
// Station — order matters: longer patterns first
|
|
{ pattern: /_SS\d+_S(?:T)?PB$/i, path: 'Station/Start_Stop' },
|
|
{ pattern: /_CH\d*_EN\d*_PB$/i, path: 'Station/Jam_Reset_Chute_Bank' },
|
|
{ pattern: /_JR\d*_PB$/i, path: 'Station/Jam_Reset' },
|
|
{ pattern: /_S\d+_PB$/i, path: 'Station/Start' },
|
|
// Beacon
|
|
{ pattern: /_BCN\d*$/i, path: 'Beacon' },
|
|
// Solenoid (including DIV*_SOL)
|
|
{ pattern: /_SOL\d*$/i, path: 'Solenoid' },
|
|
// Diverter (DIV*_LS)
|
|
{ pattern: /_DIV\d+_LS\d*$/i, path: 'Diverter' },
|
|
// PDP
|
|
{ pattern: /^PDP\d*/i, path: 'PDP' },
|
|
];
|
|
|
|
/** Build Ignition tag path from device label and MCM name.
|
|
* Format: System/{MCM}/{Category}/{SubCategory}/{Label}
|
|
* Mappings derived from Excel device manifest suffixes. */
|
|
function getIgnitionTagPath(label: string, mcm: string): string | null {
|
|
if (!label) return null;
|
|
if (/^MCM\d*/i.test(label)) return null;
|
|
for (const rule of TAG_PATH_RULES) {
|
|
if (rule.pattern.test(label)) return `System/${mcm}/${rule.path}/${label}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Build Ignition data attributes for a symbol group element */
|
|
function getIgnitionAttrs(label: string): string {
|
|
const mcm = layout.currentMcm || 'MCM01';
|
|
const tagPath = getIgnitionTagPath(label, mcm);
|
|
let attrs = ` data-color="#000000" data-state="OFF" data-priority="No Alarms"`;
|
|
if (tagPath) attrs += ` data-tagpath="${tagPath}"`;
|
|
return attrs;
|
|
}
|
|
|
|
/** Serialize child elements of an SVG, stripping xmlns added by XMLSerializer */
|
|
function serializeChildren(parent: Element): string {
|
|
return Array.from(parent.children)
|
|
.map(el => new XMLSerializer().serializeToString(el)
|
|
.replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, ''))
|
|
.join('\n ');
|
|
}
|
|
|
|
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"`,
|
|
' xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"',
|
|
' xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">',
|
|
` <rect width="${layout.canvasW}" height="${layout.canvasH}" fill="#ffffff" />`,
|
|
];
|
|
|
|
// Overlay types render LAST (on top) so they're clickable in SCADA
|
|
const OVERLAY_IDS = new Set([
|
|
'photoeye', 'photoeye_v',
|
|
'fio_sio_fioh', 'fio_sio_fioh_v',
|
|
'dpm', 'dpm_v',
|
|
'pdp', 'pdp_v',
|
|
'mcm', 'mcm_v',
|
|
'beacon', 'beacon_v',
|
|
'solenoid', 'solenoid_v',
|
|
'jam_reset', 'jam_reset_v',
|
|
'start', 'start_v',
|
|
'start_stop', 'start_stop_v',
|
|
'chute_enable', 'chute_enable_v',
|
|
'package_release', 'package_release_v',
|
|
'pressure_sensor', 'pressure_sensor_v',
|
|
'ip_camera', 'ip_camera_v',
|
|
'diverter', 'diverter_v',
|
|
'epc', 'epc_v',
|
|
]);
|
|
|
|
const visible = layout.symbols.filter(s => !s.hidden && !layout.hiddenGroups.has(getSymbolGroup(s.symbolId)));
|
|
const baseSymbols = visible.filter(s => !OVERLAY_IDS.has(s.symbolId));
|
|
const overlaySymbols = visible.filter(s => OVERLAY_IDS.has(s.symbolId));
|
|
|
|
for (const sym of [...baseSymbols, ...overlaySymbols]) {
|
|
const rot = sym.rotation || 0;
|
|
const mirrored = sym.mirrored || false;
|
|
const cx = sym.x + sym.w / 2;
|
|
const cy = sym.y + sym.h / 2;
|
|
const label = sym.label || sym.name;
|
|
const igAttrs = getIgnitionAttrs(label);
|
|
const idAttr = `id="${label}" inkscape:label="${label}"${igAttrs}`;
|
|
|
|
// Build outer transform (rotation + mirror)
|
|
const outerParts: string[] = [];
|
|
if (rot) outerParts.push(`rotate(${rot},${cx},${cy})`);
|
|
if (mirrored) outerParts.push(`translate(${cx},0) scale(-1,1) translate(${-cx},0)`);
|
|
const outerTransform = outerParts.join(' ');
|
|
|
|
if (isEpcType(sym.symbolId)) {
|
|
await emitEpc(lines, sym as PlacedSymbol, label, outerTransform);
|
|
} else if (isInductionType(sym.symbolId)) {
|
|
const hw = INDUCTION_CONFIG.headWidth;
|
|
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
|
|
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
|
|
const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
|
|
const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`;
|
|
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
|
|
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
|
|
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
|
|
lines.push(` </g>`);
|
|
} else if (isCurvedType(sym.symbolId)) {
|
|
const angle = sym.curveAngle || 90;
|
|
const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
|
const sweepRad = (angle * Math.PI) / 180;
|
|
const outerEndX = arcCx + outerR * Math.cos(sweepRad);
|
|
const outerEndY = arcCy - outerR * Math.sin(sweepRad);
|
|
const innerEndX = arcCx + innerR * Math.cos(sweepRad);
|
|
const innerEndY = arcCy - innerR * Math.sin(sweepRad);
|
|
const largeArc = angle > 180 ? 1 : 0;
|
|
const d = [
|
|
`M ${arcCx + outerR},${arcCy}`,
|
|
`A ${outerR},${outerR} 0 ${largeArc},0 ${outerEndX},${outerEndY}`,
|
|
`L ${innerEndX},${innerEndY}`,
|
|
`A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`,
|
|
'Z',
|
|
].join(' ');
|
|
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
|
|
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
|
|
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
|
|
lines.push(` </g>`);
|
|
} else if (isSpurType(sym.symbolId)) {
|
|
const w2 = sym.w2 ?? sym.w;
|
|
const d = `M ${sym.x},${sym.y} L ${sym.x + w2},${sym.y} L ${sym.x + sym.w},${sym.y + sym.h} L ${sym.x},${sym.y + sym.h} Z`;
|
|
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
|
|
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
|
|
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
|
|
lines.push(` </g>`);
|
|
} else if (isRectConveyanceType(sym.symbolId)) {
|
|
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
|
|
lines.push(` <rect x="${sym.x}" y="${sym.y}" width="${sym.w}" height="${sym.h}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
|
|
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
|
|
lines.push(` </g>`);
|
|
} else if (isExtendoType(sym.symbolId)) {
|
|
const bracketW = EXTENDO_CONFIG.bracketWidthRatio * EXTENDO_CONFIG.defaultWidth;
|
|
const p = EXTENDO_CONFIG.points;
|
|
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
|
|
const pts = [
|
|
[x + bracketW * p.tabTopRight.x, y + h * p.tabTopRight.y],
|
|
[x + bracketW * p.bracketTopRight.x, y + h * p.bracketTopRight.y],
|
|
[x + bracketW * p.beltTop.x, y + h * p.beltTop.y],
|
|
[x + w, y + h * p.beltTop.y],
|
|
[x + w, y + h * p.beltBottom.y],
|
|
[x + bracketW * p.beltBottom.x, y + h * p.beltBottom.y],
|
|
[x + bracketW * p.bracketBottomRight.x, y + h * p.bracketBottomRight.y],
|
|
[x + bracketW * p.bracketBottomLeft.x, y + h * p.bracketBottomLeft.y],
|
|
[x + bracketW * p.notchBottom.x, y + h * p.notchBottom.y],
|
|
[x, y + h * p.farLeftBottom.y],
|
|
[x, y + h * p.farLeftTop.y],
|
|
[x + bracketW * p.tabTopLeft.x, y + h * p.tabTopLeft.y],
|
|
];
|
|
const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z';
|
|
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
|
|
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
|
|
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
|
|
lines.push(` </g>`);
|
|
} else if (isPhotoeyeType(sym.symbolId)) {
|
|
const { leftCap, rightCap } = PHOTOEYE_CONFIG;
|
|
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
|
|
const pts = [
|
|
[x + leftCap, y + h * 0.42],
|
|
[x + leftCap, y + h * 0.248],
|
|
[x, y + h * 0.05],
|
|
[x, y + h * 0.948],
|
|
[x + leftCap, y + h * 0.744],
|
|
[x + leftCap, y + h * 0.585],
|
|
[x + w - rightCap, y + h * 0.585],
|
|
[x + w - rightCap, y + h * 0.826],
|
|
[x + w, y + h * 0.826],
|
|
[x + w, y + h * 0.181],
|
|
[x + w - rightCap, y + h * 0.181],
|
|
[x + w - rightCap, y + h * 0.42],
|
|
];
|
|
const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z';
|
|
lines.push(` <path ${idAttr} d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
|
|
} else {
|
|
// Regular SVG symbol
|
|
try {
|
|
const svgText = await (await fetch(sym.file)).text();
|
|
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
const svgEl = doc.documentElement;
|
|
if (svgEl.querySelector('parsererror')) {
|
|
console.error('SVG parse error for:', sym.file);
|
|
continue;
|
|
}
|
|
const vb = svgEl.getAttribute('viewBox');
|
|
const [vbX, vbY, vbW, vbH] = vb
|
|
? vb.split(/[\s,]+/).map(Number)
|
|
: [0, 0, sym.w, sym.h];
|
|
const sx = sym.w / vbW;
|
|
const sy = sym.h / vbH;
|
|
|
|
// Base positioning transform
|
|
let baseTransform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
|
if (mirrored) baseTransform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${baseTransform}`;
|
|
if (rot) baseTransform = `rotate(${rot},${cx},${cy}) ${baseTransform}`;
|
|
|
|
const children = Array.from(svgEl.children);
|
|
|
|
if (children.length === 1 && children[0].tagName === 'g') {
|
|
// SVG has a <g> wrapper — keep it, put id/label on it, compose transforms
|
|
const g = children[0];
|
|
const gTransform = g.getAttribute('transform') || '';
|
|
const fullTransform = gTransform ? `${baseTransform} ${gTransform}` : baseTransform;
|
|
const innerContent = serializeChildren(g);
|
|
lines.push(` <g ${idAttr} transform="${fullTransform}">`);
|
|
lines.push(` ${innerContent}`);
|
|
lines.push(' </g>');
|
|
} else if (children.length === 1) {
|
|
// Single element, no group — put id/label/transform directly on it
|
|
const el = children[0].cloneNode(true) as Element;
|
|
const elTransform = el.getAttribute('transform');
|
|
el.setAttribute('transform', elTransform ? `${baseTransform} ${elTransform}` : baseTransform);
|
|
el.setAttribute('id', label);
|
|
let s = new XMLSerializer().serializeToString(el)
|
|
.replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '');
|
|
// Inject inkscape:label after the tag name
|
|
const firstSpace = s.indexOf(' ');
|
|
s = s.slice(0, firstSpace) + ` inkscape:label="${label}"` + s.slice(firstSpace);
|
|
lines.push(` ${s}`);
|
|
} else {
|
|
// Multiple children without a group — wrap in <g> with id/label
|
|
const innerContent = serializeChildren(svgEl);
|
|
lines.push(` <g ${idAttr} transform="${baseTransform}">`);
|
|
lines.push(` ${innerContent}`);
|
|
lines.push(' </g>');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to embed symbol:', sym.name, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Embed layout data as metadata for re-import
|
|
const layoutData = {
|
|
symbols: layout.symbols.filter(s => !s.hidden && !layout.hiddenGroups.has(getSymbolGroup(s.symbolId))).map(s => serializeSymbol(s)),
|
|
gridSize: layout.gridSize,
|
|
minSpacing: layout.minSpacing,
|
|
canvasW: layout.canvasW,
|
|
canvasH: layout.canvasH,
|
|
};
|
|
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([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';
|
|
}
|
|
|
|
/** Build Ignition ia.shapes.svg component data */
|
|
async function buildIgnitionComponent(): Promise<Record<string, any>> {
|
|
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)) {
|
|
if (child.nodeType === 8) continue;
|
|
const converted = svgElementToIgnition(child);
|
|
if (converted) elements.push(converted);
|
|
}
|
|
|
|
const mcmName = layout.currentMcm || 'export';
|
|
return {
|
|
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: {},
|
|
};
|
|
}
|
|
|
|
/** Export as Ignition SCADA JSON file (download) */
|
|
export async function exportIgnitionJSON() {
|
|
const component = await buildIgnitionComponent();
|
|
const mcmName = layout.currentMcm || 'export';
|
|
downloadBlob(
|
|
new Blob([JSON.stringify([component], null, 2)], { type: 'application/json' }),
|
|
`${mcmName}_Detailed_View.json`
|
|
);
|
|
}
|
|
|
|
/** Deploy directly to Ignition project directory */
|
|
export async function deployToIgnition() {
|
|
const component = await buildIgnitionComponent();
|
|
const projectName = layout.ignitionProject || 'CDW5_SCADA';
|
|
const viewName = layout.ignitionViewName || layout.currentMcm || 'MCM01';
|
|
const viewPath = layout.ignitionViewPath || 'DetailedView';
|
|
|
|
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);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', viewBytes);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
const signature = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
const resourceJson = JSON.stringify({
|
|
scope: 'G',
|
|
version: 1,
|
|
restricted: false,
|
|
overridable: true,
|
|
files: ['view.json'],
|
|
attributes: {
|
|
lastModificationSignature: signature,
|
|
lastModification: {
|
|
actor: 'admin',
|
|
timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
},
|
|
},
|
|
}, null, 2);
|
|
|
|
try {
|
|
const resp = await fetch('/api/deploy-ignition', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectName, viewName, viewPath, viewJson, resourceJson }),
|
|
});
|
|
const result = await resp.json();
|
|
if (result.ok) {
|
|
// Trigger project rescan via gateway scan endpoint
|
|
try {
|
|
await fetch('/api/ignition-scan', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectName }),
|
|
});
|
|
} catch { /* scan is best-effort */ }
|
|
alert(`Deployed to Ignition!\n${result.path}\n\nClose and reopen the project in Designer to see the view.`);
|
|
} else {
|
|
alert(`Deploy failed: ${result.error}`);
|
|
}
|
|
} catch (err) {
|
|
alert(`Deploy failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
/** Emit EPC symbol — polyline + icon + right box, wrapped in <g> with id/label */
|
|
async function emitEpc(lines: string[], sym: PlacedSymbol, label: string, outerTransform: string) {
|
|
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
|
|
const ox = sym.x;
|
|
const oy = sym.y;
|
|
const parts: string[] = [];
|
|
const tAttr = outerTransform ? ` transform="${outerTransform}"` : '';
|
|
|
|
// Polyline
|
|
if (waypoints.length >= 2) {
|
|
const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' ');
|
|
parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" />`);
|
|
}
|
|
|
|
if (waypoints.length >= 2) {
|
|
// Left icon
|
|
const lb = EPC_CONFIG.leftBox;
|
|
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
|
|
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
|
|
const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI;
|
|
|
|
try {
|
|
const svgText = await (await fetch(EPC_CONFIG.iconFile)).text();
|
|
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
const svgEl = doc.documentElement;
|
|
const vb = svgEl.getAttribute('viewBox');
|
|
const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h];
|
|
const sx = lb.w / vbW;
|
|
const sy = lb.h / vbH;
|
|
const iconTransform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
|
|
|
const children = Array.from(svgEl.children);
|
|
if (children.length === 1 && children[0].tagName === 'g') {
|
|
const g = children[0];
|
|
const gT = g.getAttribute('transform') || '';
|
|
const fullT = gT ? `${iconTransform} ${gT}` : iconTransform;
|
|
parts.push(` <g transform="${fullT}">`);
|
|
parts.push(` ${serializeChildren(g)}`);
|
|
parts.push(` </g>`);
|
|
} else {
|
|
parts.push(` <g transform="${iconTransform}">`);
|
|
parts.push(` ${serializeChildren(svgEl)}`);
|
|
parts.push(` </g>`);
|
|
}
|
|
} catch {
|
|
parts.push(` <rect x="${-lb.w}" y="${-lb.h / 2}" width="${lb.w}" height="${lb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" transform="translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})" />`);
|
|
}
|
|
|
|
// Right box
|
|
const last = waypoints[waypoints.length - 1];
|
|
const prev = waypoints[waypoints.length - 2];
|
|
const plx = ox + last.x, ply = oy + last.y;
|
|
const ppx = ox + prev.x, ppy = oy + prev.y;
|
|
const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI;
|
|
const rb = EPC_CONFIG.rightBox;
|
|
parts.push(` <rect x="${-rb.w}" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
|
|
}
|
|
|
|
lines.push(` <g id="${label}" inkscape:label="${label}"${tAttr}>`);
|
|
lines.push(parts.join('\n'));
|
|
lines.push(' </g>');
|
|
}
|
|
|
|
export function loadLayoutSVG(file: File): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
try {
|
|
const svgText = ev.target!.result as string;
|
|
const match = svgText.match(/<!-- LAYOUT_DATA:(.*?):END_LAYOUT_DATA -->/);
|
|
if (!match) throw new Error('No layout data found in SVG. Only SVGs exported from this tool can be imported.');
|
|
const data = JSON.parse(match[1]);
|
|
layout.pushUndo();
|
|
if (data.gridSize) layout.gridSize = data.gridSize;
|
|
if (data.minSpacing) layout.minSpacing = data.minSpacing;
|
|
if (data.canvasW) layout.canvasW = data.canvasW;
|
|
if (data.canvasH) layout.canvasH = data.canvasH;
|
|
layout.symbols = [];
|
|
layout.nextId = 1;
|
|
for (const s of data.symbols) {
|
|
layout.symbols.push(deserializeSymbol(s, layout.nextId++));
|
|
}
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
resolve();
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
export function loadLayoutJSON(file: File): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.target!.result as string);
|
|
layout.pushUndo();
|
|
if (data.gridSize) layout.gridSize = data.gridSize;
|
|
if (data.minSpacing) layout.minSpacing = data.minSpacing;
|
|
layout.symbols = [];
|
|
layout.nextId = 1;
|
|
for (const s of data.symbols) {
|
|
layout.symbols.push(deserializeSymbol(s, layout.nextId++));
|
|
}
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
resolve();
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
}
|