Add internal labels for conveyance symbols (canvas + SVG export)

Parse labels like UL17_22_VFD into stacked text: "UL" / "17-22".
Bold black Arial, targets 14px but auto-scales down to fit with
consistent padding. Strips _VFD suffix, splits prefix from numbers.
If full text doesn't fit, strips the letter prefix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-30 18:23:19 +04:00
parent 09cafa4577
commit 07cee1c151
2 changed files with 119 additions and 12 deletions

View File

@ -522,6 +522,72 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole
return true;
}
/** Parse a conveyance label like "UL17_22_VFD" into display lines.
* Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers,
* replaces underscores with dashes in the number part. */
function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
// Strip known device suffixes
let core = label.replace(/_?VFD\d*$/i, '');
if (core === label) core = label; // no suffix found, use as-is
// Split into letter prefix and rest
const m = core.match(/^([A-Za-z]+)(.*)$/);
if (m) {
const prefix = m[1];
const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
if (nums) {
return { lines: [prefix, nums], stripped: [nums] };
}
return { lines: [prefix], stripped: [prefix] };
}
return { lines: [core], stripped: [core] };
}
/** Draw label inside a conveyance symbol — black bold text, auto-sized to fit */
function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!sym.label) return;
const { lines, stripped } = parseConveyanceLabel(sym.label);
const pad = 2;
const availW = sym.w - pad * 2;
const availH = sym.h - pad * 2;
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Try full lines first, then stripped (prefix removed) if it doesn't fit
for (const tryLines of [lines, stripped]) {
// Target font size: 14px, but scale down to fit
let fontSize = 14;
const lineCount = tryLines.length;
const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1;
// Scale down if too tall
while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5;
// Scale down if too wide
ctx.font = `bold ${fontSize}px Arial`;
let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
while (maxW > availW && fontSize > 4) {
fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
}
if (fontSize >= 4) {
ctx.font = `bold ${fontSize}px Arial`;
const lineH = fontSize;
const startY = cy - (lineCount - 1) * lineH / 2;
for (let i = 0; i < tryLines.length; i++) {
ctx.fillText(tryLines[i], cx, startY + i * lineH);
}
return;
}
}
}
function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const cx = sym.x + sym.w / 2;
const isSelected = layout.selectedIds.has(sym.id);
@ -576,20 +642,26 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
strokeOutline(ctx, sym, 0);
}
// Label: centered inside symbol if large enough, above if too small
// Label rendering
if (sym.label) {
ctx.fillStyle = THEME.label.color;
ctx.font = THEME.label.font;
const metrics = ctx.measureText(sym.label);
const textH = 3; // approximate font height
if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(sym.label, cx, sym.y + sym.h / 2);
const isConveyance = isRectConveyanceType(sym.symbolId) || isExtendoType(sym.symbolId)
|| isCurvedType(sym.symbolId) || isSpurType(sym.symbolId) || isInductionType(sym.symbolId);
if (isConveyance) {
drawConveyanceLabel(ctx, sym);
} else {
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
ctx.fillStyle = THEME.label.color;
ctx.font = THEME.label.font;
const metrics = ctx.measureText(sym.label);
const textH = 3;
if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(sym.label, cx, sym.y + sym.h / 2);
} else {
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
}
}
}
}

View File

@ -3,6 +3,36 @@ import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceT
import { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js';
/** Parse conveyance label into display lines — same logic as renderer */
function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
let core = label.replace(/_?VFD\d*$/i, '');
if (core === label) core = label;
const m = core.match(/^([A-Za-z]+)(.*)$/);
if (m) {
const prefix = m[1];
const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
if (nums) return { lines: [prefix, nums], stripped: [nums] };
return { lines: [prefix], stripped: [prefix] };
}
return { lines: [core], stripped: [core] };
}
/** Emit conveyance label text element inside the shape */
function emitConveyanceLabel(lines: string[], sym: PlacedSymbol, outerTransform: string) {
if (!sym.label) return;
const { lines: textLines } = parseConveyanceLabel(sym.label);
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
const fontSize = Math.min(14, (sym.h - 4) / textLines.length);
if (fontSize < 4) return;
const lineH = fontSize;
const startY = cy - (textLines.length - 1) * lineH / 2;
const tAttr = outerTransform ? ` transform="${outerTransform}"` : '';
for (let i = 0; i < textLines.length; i++) {
lines.push(` <text x="${cx}" y="${startY + i * lineH}" text-anchor="middle" dominant-baseline="central" font-family="Arial" font-weight="bold" font-size="${fontSize}px" fill="#000000"${tAttr}>${textLines[i]}</text>`);
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -65,6 +95,7 @@ export async function exportSVG() {
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(` <path ${idAttr} d="${d}" fill="#ffffff" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} 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);
@ -82,12 +113,15 @@ export async function exportSVG() {
'Z',
].join(' ');
lines.push(` <path ${idAttr} d="${d}" fill="#ffffff" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} 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(` <path ${idAttr} d="${d}" fill="#ffffff" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isRectConveyanceType(sym.symbolId)) {
lines.push(` <rect ${idAttr} x="${sym.x}" y="${sym.y}" width="${sym.w}" height="${sym.h}" fill="#ffffff" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isExtendoType(sym.symbolId)) {
const bracketW = 10.6 / 31.07 * 73;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
@ -107,6 +141,7 @@ export async function exportSVG() {
];
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="#ffffff" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isPhotoeyeType(sym.symbolId)) {
const { leftCap, rightCap } = PHOTOEYE_CONFIG;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;