igurielidze 49d426eef3 Spur label: center at midpoint between w2 and w (right half)
Text centered at x = (w2 + w) / 2 — the middle of the wide right
portion of the trapezoid where there's the most space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:36:10 +04:00

782 lines
28 KiB
TypeScript

import { layout } from '../stores/layout.svelte.js';
import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, isRectConveyanceType, isExtendoType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js';
import { checkSpacingViolation } from './collision.js';
import { marqueeRect } from './interactions.js';
import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js';
let ctx: CanvasRenderingContext2D | null = null;
let canvas: HTMLCanvasElement | null = null;
let animFrameId: number | null = null;
let lastDirty = -1;
const MAX_RENDER_SCALE = THEME.canvas.maxRenderScale;
let currentRenderScale = 0;
let lastCanvasW = 0;
let lastCanvasH = 0;
export function setCanvas(c: HTMLCanvasElement) {
canvas = c;
ctx = c.getContext('2d')!;
currentRenderScale = 0; // force resolution update on first render
}
function updateCanvasResolution() {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE);
const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH;
if (targetScale === currentRenderScale && !sizeChanged) return;
currentRenderScale = targetScale;
lastCanvasW = layout.canvasW;
lastCanvasH = layout.canvasH;
canvas.width = layout.canvasW * currentRenderScale;
canvas.height = layout.canvasH * currentRenderScale;
canvas.style.width = layout.canvasW + 'px';
canvas.style.height = layout.canvasH + 'px';
}
export function startRenderLoop() {
function loop() {
animFrameId = requestAnimationFrame(loop);
const dpr = window.devicePixelRatio || 1;
const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE);
const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH;
if (layout.dirty !== lastDirty || targetScale !== currentRenderScale || sizeChanged) {
lastDirty = layout.dirty;
render();
}
}
loop();
}
export function stopRenderLoop() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
}
export function render() {
if (!ctx || !canvas) return;
updateCanvasResolution();
ctx.save();
ctx.setTransform(currentRenderScale, 0, 0, currentRenderScale, 0, 0);
ctx.clearRect(0, 0, layout.canvasW, layout.canvasH);
if (layout.showGrid) {
drawGrid(ctx);
}
// Draw non-overlay symbols first, then overlay symbols (photoeyes/FIOs) on top
// Skip hidden symbols (individually hidden or group hidden)
for (const sym of layout.symbols) {
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
}
for (const sym of layout.symbols) {
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
}
// Marquee selection rectangle
if (marqueeRect) {
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.fillStyle = 'rgba(74, 158, 255, 0.1)';
ctx.fillRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
ctx.strokeRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
ctx.setLineDash([]);
}
ctx.restore();
}
function drawGrid(ctx: CanvasRenderingContext2D) {
const size = layout.gridSize;
ctx.strokeStyle = THEME.grid.color;
ctx.lineWidth = THEME.grid.lineWidth;
ctx.beginPath();
for (let x = 0; x <= layout.canvasW; x += size) {
ctx.moveTo(x, 0);
ctx.lineTo(x, layout.canvasH);
}
for (let y = 0; y <= layout.canvasH; y += size) {
ctx.moveTo(0, y);
ctx.lineTo(layout.canvasW, y);
}
ctx.stroke();
}
/** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */
function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
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;
ctx.beginPath();
ctx.arc(arcCx, arcCy, outerR + pad, 0, -sweepRad, true);
ctx.arc(arcCx, arcCy, Math.max(0, innerR - pad), -sweepRad, 0, false);
ctx.closePath();
}
/** Trace the spur trapezoid path (for selection/collision strokes) */
function traceSpurPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
const w2 = sym.w2 ?? sym.w;
// Expand vertices outward by pad
ctx.beginPath();
ctx.moveTo(sym.x - pad, sym.y - pad);
ctx.lineTo(sym.x + w2 + pad, sym.y - pad);
ctx.lineTo(sym.x + sym.w + pad, sym.y + sym.h + pad);
ctx.lineTo(sym.x - pad, sym.y + sym.h + pad);
ctx.closePath();
}
/** Draw the EPC symbol: SVG image for left icon, programmatic polyline + right box.
* Both boxes auto-orient along the line direction at their respective ends. */
function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
const ox = sym.x; // origin x
const oy = sym.y; // origin y
// Draw polyline connecting waypoints
if (waypoints.length >= 2) {
ctx.beginPath();
ctx.moveTo(ox + waypoints[0].x, oy + waypoints[0].y);
for (let i = 1; i < waypoints.length; i++) {
ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y);
}
ctx.strokeStyle = THEME.epcBody.lineColor;
ctx.lineWidth = EPC_CONFIG.lineWidth;
ctx.stroke();
}
// --- Left icon: use actual SVG image, oriented along first segment ---
if (waypoints.length >= 2) {
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 angle = Math.atan2(p1y - p0y, p1x - p0x);
const iconImg = getSymbolImage(EPC_CONFIG.iconFile);
if (iconImg) {
ctx.save();
ctx.translate(p0x, p0y);
ctx.rotate(angle);
ctx.drawImage(iconImg, -lb.w, -lb.h / 2, lb.w, lb.h);
ctx.restore();
}
}
// --- Right box: oriented along direction from wp[n-2] to wp[n-1] ---
if (waypoints.length >= 2) {
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 angle = Math.atan2(ply - ppy, plx - ppx);
ctx.save();
ctx.translate(plx, ply);
ctx.rotate(angle);
const rb = EPC_CONFIG.rightBox;
ctx.fillStyle = THEME.epcBody.rightBoxFill;
ctx.strokeStyle = THEME.epcBody.rightBoxStroke;
ctx.lineWidth = THEME.epcBody.rightBoxStrokeWidth;
ctx.fillRect(-rb.w, -rb.h / 2, rb.w, rb.h);
ctx.strokeRect(-rb.w, -rb.h / 2, rb.w, rb.h);
ctx.restore();
}
}
/** Draw EPC waypoint handles when selected */
function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
const hs = THEME.epcWaypoint.size;
ctx.fillStyle = THEME.epcWaypoint.fillColor;
ctx.strokeStyle = THEME.epcWaypoint.strokeColor;
ctx.lineWidth = THEME.epcWaypoint.lineWidth;
for (const wp of waypoints) {
const hx = sym.x + wp.x;
const hy = sym.y + wp.y;
ctx.beginPath();
ctx.arc(hx, hy, hs / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Draw "+" at midpoints of segments to hint add-waypoint
if (waypoints.length >= 2) {
ctx.fillStyle = THEME.epcWaypoint.fillColor;
ctx.font = THEME.epcWaypoint.hintFont;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < waypoints.length - 1; i++) {
const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2;
const my = sym.y + (waypoints[i].y + waypoints[i + 1].y) / 2;
ctx.fillText('+', mx, my + THEME.epcWaypoint.hintOffsetY);
}
}
}
/** Trace the EPC outline path: left box + segments + right box */
function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
if (waypoints.length < 2) return;
const ox = sym.x, oy = sym.y;
// Draw left box outline
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);
const lb = EPC_CONFIG.leftBox;
ctx.save();
ctx.translate(p0x, p0y);
ctx.rotate(lAngle);
ctx.beginPath();
ctx.rect(-lb.w - pad, -lb.h / 2 - pad, lb.w + pad * 2, lb.h + pad * 2);
ctx.stroke();
ctx.restore();
// Draw line segments outline (thickened)
for (let i = 0; i < waypoints.length - 1; i++) {
const ax = ox + waypoints[i].x, ay = oy + waypoints[i].y;
const bx = ox + waypoints[i + 1].x, by = oy + waypoints[i + 1].y;
const segAngle = Math.atan2(by - ay, bx - ax);
const segLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
ctx.save();
ctx.translate(ax, ay);
ctx.rotate(segAngle);
ctx.beginPath();
ctx.rect(-pad, -pad - EPC_CONFIG.lineWidth / 2, segLen + pad * 2, EPC_CONFIG.lineWidth + pad * 2);
ctx.stroke();
ctx.restore();
}
// Draw right box outline
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);
const rb = EPC_CONFIG.rightBox;
ctx.save();
ctx.translate(plx, ply);
ctx.rotate(rAngle);
ctx.beginPath();
ctx.rect(-rb.w - pad, -rb.h / 2 - pad, rb.w + pad * 2, rb.h + pad * 2);
ctx.stroke();
ctx.restore();
}
/** Trace the induction outline path (arrow head + strip) */
function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
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);
ctx.beginPath();
ctx.moveTo(sym.x + sym.w + pad, stripTopY - pad);
ctx.lineTo(pts[0][0], stripTopY - pad);
// Arrow outline with padding
for (let i = 0; i < pts.length; i++) {
const [px, py] = pts[i];
// Simple approach: offset each point outward by pad
ctx.lineTo(px + (i <= 2 ? -pad : pad), py + (i <= 1 ? -pad : pad));
}
ctx.lineTo(pts[5][0], stripBottomY + pad);
ctx.lineTo(sym.x + sym.w + pad, stripBottomY + pad);
ctx.closePath();
}
/** Trace photoeye outline path */
function tracePhotoeyePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
const { leftCap, rightCap } = PHOTOEYE_CONFIG;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
const p = pad;
ctx.beginPath();
ctx.moveTo(x + leftCap + p, y + h * 0.42 - p);
ctx.lineTo(x + leftCap + p, y + h * 0.248 - p);
ctx.lineTo(x - p, y + h * 0.05 - p);
ctx.lineTo(x - p, y + h * 0.948 + p);
ctx.lineTo(x + leftCap + p, y + h * 0.744 + p);
ctx.lineTo(x + leftCap + p, y + h * 0.585 + p);
ctx.lineTo(x + w - rightCap - p, y + h * 0.585 + p);
ctx.lineTo(x + w - rightCap - p, y + h * 0.826 + p);
ctx.lineTo(x + w + p, y + h * 0.826 + p);
ctx.lineTo(x + w + p, y + h * 0.181 - p);
ctx.lineTo(x + w - rightCap - p, y + h * 0.181 - p);
ctx.lineTo(x + w - rightCap - p, y + h * 0.42 - p);
ctx.closePath();
}
/** Stroke an outline around a symbol — uses arc path for curved, trapezoid for spur, EPC shape for EPC, induction shape for induction, rect for straight */
function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
if (isCurvedType(sym.symbolId)) {
traceArcBandPath(ctx, sym, pad);
ctx.stroke();
} else if (isSpurType(sym.symbolId)) {
traceSpurPath(ctx, sym, pad);
ctx.stroke();
} else if (isEpcType(sym.symbolId)) {
traceEpcOutlinePath(ctx, sym, pad);
} else if (isInductionType(sym.symbolId)) {
traceInductionPath(ctx, sym, pad);
ctx.stroke();
} else if (isPhotoeyeType(sym.symbolId)) {
tracePhotoeyePath(ctx, sym, pad);
ctx.stroke();
} else {
ctx.strokeRect(sym.x - pad, sym.y - pad, sym.w + pad * 2, sym.h + pad * 2);
}
}
/** Draw a filled+stroked resize handle at (x, y) */
function drawHandle(ctx: CanvasRenderingContext2D, x: number, y: number, size: number) {
const half = size / 2;
ctx.fillRect(x - half, y - half, size, size);
ctx.strokeRect(x - half, y - half, size, size);
}
function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!isResizable(sym.symbolId)) return;
const hs = THEME.resizeHandle.size;
ctx.fillStyle = THEME.resizeHandle.fillColor;
ctx.strokeStyle = THEME.resizeHandle.strokeColor;
ctx.lineWidth = THEME.resizeHandle.lineWidth;
if (isCurvedType(sym.symbolId)) {
const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
drawHandle(ctx, arcCx + outerR, arcCy, hs);
} else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Right handle on top base (controls w2)
drawHandle(ctx, sym.x + w2, sym.y, hs);
// Right handle on bottom base (controls w)
drawHandle(ctx, sym.x + sym.w, sym.y + sym.h, hs);
} else if (isInductionType(sym.symbolId)) {
// Only right handle — arrow head is fixed width
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
const stripMidY = (stripTopY + stripBottomY) / 2;
drawHandle(ctx, sym.x + sym.w, stripMidY, hs);
} else {
const midY = sym.y + sym.h / 2;
drawHandle(ctx, sym.x, midY, hs);
drawHandle(ctx, sym.x + sym.w, midY, hs);
}
}
/** Draw induction programmatically: fixed arrow head + variable strip, as one path */
function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const hw = INDUCTION_CONFIG.headWidth;
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
// Arrow points in display coords
const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
ctx.beginPath();
// Top-right of strip
ctx.moveTo(sym.x + sym.w, stripTopY);
// Top-left junction (arrow meets strip)
ctx.lineTo(pts[0][0], stripTopY);
// Arrow outline
for (const [px, py] of pts) {
ctx.lineTo(px, py);
}
// Bottom-left junction to strip bottom
ctx.lineTo(pts[5][0], stripBottomY);
// Bottom-right of strip
ctx.lineTo(sym.x + sym.w, stripBottomY);
ctx.closePath();
ctx.fillStyle = THEME.induction.fillColor;
ctx.strokeStyle = THEME.induction.strokeColor;
ctx.lineWidth = THEME.induction.lineWidth;
ctx.fill();
ctx.stroke();
}
/** Draw photoeye programmatically for consistent stroke at any size */
function drawPhotoeyeSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const { leftCap, rightCap } = PHOTOEYE_CONFIG;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
// Y positions as fractions of height (derived from original SVG path)
const beamTop = y + h * 0.42;
const beamBottom = y + h * 0.585;
const arrowInnerTop = y + h * 0.248;
const arrowInnerBottom = y + h * 0.744;
const recvTop = y + h * 0.181;
const recvBottom = y + h * 0.826;
const arrowTipTop = y + h * 0.05;
const arrowTipBottom = y + h * 0.948;
ctx.beginPath();
ctx.moveTo(x + leftCap, beamTop);
ctx.lineTo(x + leftCap, arrowInnerTop);
ctx.lineTo(x, arrowTipTop);
ctx.lineTo(x, arrowTipBottom);
ctx.lineTo(x + leftCap, arrowInnerBottom);
ctx.lineTo(x + leftCap, beamBottom);
ctx.lineTo(x + w - rightCap, beamBottom);
ctx.lineTo(x + w - rightCap, recvBottom);
ctx.lineTo(x + w, recvBottom);
ctx.lineTo(x + w, recvTop);
ctx.lineTo(x + w - rightCap, recvTop);
ctx.lineTo(x + w - rightCap, beamTop);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
}
/** Draw curved conveyor/chute programmatically with fixed band width */
function drawCurvedSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
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;
ctx.beginPath();
ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true);
ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
}
function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean {
if (isEpcType(sym.symbolId)) {
drawEpcSymbol(ctx, sym);
} else if (isInductionType(sym.symbolId)) {
drawInductionSymbol(ctx, sym);
} else if (isCurvedType(sym.symbolId)) {
drawCurvedSymbol(ctx, sym);
} else if (isSpurType(sym.symbolId)) {
// Draw trapezoid programmatically so w and w2 are respected during resize
const w2 = sym.w2 ?? sym.w;
ctx.beginPath();
ctx.moveTo(sym.x, sym.y);
ctx.lineTo(sym.x + w2, sym.y);
ctx.lineTo(sym.x + sym.w, sym.y + sym.h);
ctx.lineTo(sym.x, sym.y + sym.h);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
} else if (isExtendoType(sym.symbolId)) {
// Extendo: fixed left bracket + stretchy right belt
// Y fractions from original SVG path, X uses fixed left bracket width
const bracketW = 10.6 / 31.07 * 73; // ~24.9 px at default 73w — fixed portion
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
ctx.beginPath();
ctx.moveTo(x + bracketW * 0.44, y + h * 0.085); // tab top-right
ctx.lineTo(x + bracketW, y + h * 0.085); // bracket top-right
ctx.lineTo(x + bracketW, y + h * 0.222); // step down to belt top
ctx.lineTo(x + w, y + h * 0.222); // belt top-right
ctx.lineTo(x + w, y + h * 0.780); // belt bottom-right
ctx.lineTo(x + bracketW, y + h * 0.780); // belt bottom-left
ctx.lineTo(x + bracketW, y + h * 0.917); // step down bracket bottom
ctx.lineTo(x + bracketW * 0.44, y + h * 0.916); // bracket bottom-left
ctx.lineTo(x + bracketW * 0.34, y + h * 0.985); // notch bottom
ctx.lineTo(x, y + h * 0.980); // far left bottom
ctx.lineTo(x, y + h * 0.017); // far left top
ctx.lineTo(x + bracketW * 0.34, y + h * 0.016); // tab top-left
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
} else if (isRectConveyanceType(sym.symbolId)) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fillRect(sym.x, sym.y, sym.w, sym.h);
ctx.strokeRect(sym.x, sym.y, sym.w, sym.h);
} else if (isPhotoeyeType(sym.symbolId)) {
drawPhotoeyeSymbol(ctx, sym);
} else {
const img = getSymbolImage(sym.file);
if (!img) return false;
ctx.drawImage(img, sym.x, sym.y, sym.w, sym.h);
}
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;
if (isCurvedType(sym.symbolId)) { drawCurvedLabel(ctx, sym); return; }
const { lines, stripped } = parseConveyanceLabel(sym.label);
const pad = 2;
// For spurs, center in the trapezoid shape, not bounding box
let cx: number, cy: number, availW: number, availH: number;
if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Center text in the right half (wide area) of the trapezoid
cx = sym.x + (w2 + sym.w) / 2;
cy = sym.y + sym.h / 2;
availW = sym.w;
availH = sym.h;
} else {
cx = sym.x + sym.w / 2;
cy = sym.y + sym.h / 2;
availW = sym.w - pad * 2;
availH = sym.h - pad * 2;
}
// Compute text correction so text is always readable.
// Mirror flips the effective angle: visual angle = (360 - rot) when mirrored.
const rot = ((sym.rotation || 0) % 360 + 360) % 360;
const effectiveAngle = sym.mirrored ? ((360 - rot) % 360) : rot;
const needsFlip = effectiveAngle > 90 && effectiveAngle < 270;
const needsMirrorFix = !!sym.mirrored;
const hasCorrection = needsFlip || needsMirrorFix;
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const tryLines of [lines, stripped]) {
let fontSize = 14;
const lineCount = tryLines.length;
const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1;
while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5;
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;
if (hasCorrection) {
ctx.save();
ctx.translate(cx, cy);
if (needsMirrorFix) ctx.scale(-1, 1);
if (needsFlip) ctx.rotate(Math.PI);
for (let i = 0; i < tryLines.length; i++) {
const dy = -(lineCount - 1) * lineH / 2 + i * lineH;
ctx.fillText(tryLines[i], 0, dy);
}
ctx.restore();
} else {
const startY = cy - (lineCount - 1) * lineH / 2;
for (let i = 0; i < tryLines.length; i++) {
ctx.fillText(tryLines[i], cx, startY + i * lineH);
}
}
return;
}
}
}
/** Draw label along a curved symbol's arc band */
function drawCurvedLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!sym.label) return;
const { lines, stripped } = parseConveyanceLabel(sym.label);
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; // half the sweep angle
// Position at arc midpoint
const textX = arcCx + midR * Math.cos(midAngleRad);
const textY = arcCy - midR * Math.sin(midAngleRad);
// Rotation: tangent to arc
let textRot = -midAngleRad + Math.PI / 2;
// Add symbol rotation to check readability
const symRotRad = ((sym.rotation || 0) * Math.PI) / 180;
let worldAngle = (textRot + symRotRad) % (2 * Math.PI);
if (worldAngle < 0) worldAngle += 2 * Math.PI;
// Flip if text would be upside-down
if (worldAngle > Math.PI / 2 && worldAngle < Math.PI * 3 / 2) {
textRot += Math.PI;
}
// Mirror: label follows the mirrored shape naturally
const availW = bandW - 4;
ctx.save();
ctx.translate(textX, textY);
ctx.rotate(textRot);
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const tryLines of [lines, stripped]) {
let fontSize = 14;
const lineCount = tryLines.length;
while (fontSize * lineCount > availW && fontSize > 4) fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
// Available length along the arc at midR
const arcLen = midR * (angle * Math.PI / 180) * 0.6; // use 60% of arc
while (maxW > arcLen && fontSize > 4) {
fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
}
if (fontSize >= 4) {
const lineH = fontSize;
const startY = -(lineCount - 1) * lineH / 2;
for (let i = 0; i < tryLines.length; i++) {
ctx.fillText(tryLines[i], 0, startY + i * lineH);
}
ctx.restore();
return;
}
}
ctx.restore();
}
function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const cx = sym.x + sym.w / 2;
const isSelected = layout.selectedIds.has(sym.id);
// Selection highlight
if (isSelected) {
ctx.strokeStyle = THEME.selection.strokeColor;
ctx.lineWidth = THEME.selection.lineWidth;
ctx.shadowColor = THEME.selection.shadowColor;
ctx.shadowBlur = THEME.selection.shadowBlur;
strokeOutline(ctx, sym, THEME.selection.pad);
ctx.shadowBlur = 0;
if (layout.selectedIds.size === 1) {
if (isEpcType(sym.symbolId)) {
drawEpcWaypointHandles(ctx, sym);
} else {
drawResizeHandles(ctx, sym);
}
}
}
// Collision highlight
if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2, sym.mirrored)) {
ctx.strokeStyle = THEME.collision.strokeColor;
ctx.lineWidth = THEME.collision.lineWidth;
ctx.shadowColor = THEME.collision.shadowColor;
ctx.shadowBlur = THEME.collision.shadowBlur;
strokeOutline(ctx, sym, THEME.collision.pad);
ctx.shadowBlur = 0;
}
// Label drop-target highlight
if (layout.labelDropTarget === sym.id) {
ctx.fillStyle = THEME.dropTarget.fillColor;
ctx.strokeStyle = THEME.dropTarget.strokeColor;
ctx.lineWidth = THEME.dropTarget.lineWidth;
ctx.shadowColor = THEME.dropTarget.shadowColor;
ctx.shadowBlur = THEME.dropTarget.shadowBlur;
// Fill + stroke the outline
ctx.beginPath();
ctx.rect(sym.x - THEME.dropTarget.pad, sym.y - THEME.dropTarget.pad,
sym.w + THEME.dropTarget.pad * 2, sym.h + THEME.dropTarget.pad * 2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
// Hover border (non-selected)
if (!isSelected && layout.labelDropTarget !== sym.id) {
ctx.strokeStyle = THEME.hover.strokeColor;
ctx.lineWidth = THEME.hover.lineWidth;
strokeOutline(ctx, sym, 0);
}
// Label rendering
if (sym.label) {
const isConveyance = isRectConveyanceType(sym.symbolId) || isExtendoType(sym.symbolId)
|| isCurvedType(sym.symbolId) || isSpurType(sym.symbolId) || isInductionType(sym.symbolId);
if (isConveyance) {
drawConveyanceLabel(ctx, sym);
} else {
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);
}
}
}
}
function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save();
// Apply rotation and mirror transforms
if (sym.rotation || sym.mirrored) {
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
ctx.translate(cx, cy);
if (sym.rotation) ctx.rotate((sym.rotation * Math.PI) / 180);
if (sym.mirrored) ctx.scale(-1, 1);
ctx.translate(-cx, -cy);
}
if (!drawSymbolBody(ctx, sym)) {
ctx.restore();
return;
}
drawSymbolOverlays(ctx, sym);
ctx.restore();
}