- Extract parseConveyanceLabel to shared label-utils.ts (was duplicated) - Add EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE to symbol-config.ts - Replace all hardcoded fill/stroke/lineWidth with CONVEYANCE_STYLE - Replace magic font numbers (14, 4, 0.5) with LABEL_CONFIG constants - Extract drawSpurSymbol, drawExtendoSymbol, drawRectConveyanceSymbol from inline code — drawSymbolBody is now a clean dispatch - Convert getIgnitionTagPath from 18 if-statements to data-driven table - Add THEME.marquee for selection rectangle colors - Remove no-op assignment in parseConveyanceLabel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
784 lines
28 KiB
TypeScript
784 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, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE } from '../symbols.js';
|
|
import { checkSpacingViolation } from './collision.js';
|
|
import { marqueeRect } from './interactions.js';
|
|
import { THEME } from './render-theme.js';
|
|
import { parseConveyanceLabel } from '../label-utils.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 = THEME.marquee.strokeColor;
|
|
ctx.lineWidth = THEME.marquee.lineWidth;
|
|
ctx.setLineDash(THEME.marquee.dash as number[]);
|
|
ctx.fillStyle = THEME.marquee.fillColor;
|
|
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 = CONVEYANCE_STYLE.fillColor;
|
|
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
|
|
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
|
|
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 = CONVEYANCE_STYLE.fillColor;
|
|
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
|
|
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
/** Draw spur trapezoid programmatically so w and w2 are respected during resize */
|
|
function drawSpurSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
|
|
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 = CONVEYANCE_STYLE.fillColor;
|
|
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
|
|
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
/** Draw extendo: fixed left bracket + stretchy right belt */
|
|
function drawExtendoSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
|
|
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;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + bracketW * p.tabTopRight.x, y + h * p.tabTopRight.y);
|
|
ctx.lineTo(x + bracketW * p.bracketTopRight.x, y + h * p.bracketTopRight.y);
|
|
ctx.lineTo(x + bracketW * p.beltTop.x, y + h * p.beltTop.y);
|
|
ctx.lineTo(x + w, y + h * p.beltTop.y);
|
|
ctx.lineTo(x + w, y + h * p.beltBottom.y);
|
|
ctx.lineTo(x + bracketW * p.beltBottom.x, y + h * p.beltBottom.y);
|
|
ctx.lineTo(x + bracketW * p.bracketBottomRight.x, y + h * p.bracketBottomRight.y);
|
|
ctx.lineTo(x + bracketW * p.bracketBottomLeft.x, y + h * p.bracketBottomLeft.y);
|
|
ctx.lineTo(x + bracketW * p.notchBottom.x, y + h * p.notchBottom.y);
|
|
ctx.lineTo(x, y + h * p.farLeftBottom.y);
|
|
ctx.lineTo(x, y + h * p.farLeftTop.y);
|
|
ctx.lineTo(x + bracketW * p.tabTopLeft.x, y + h * p.tabTopLeft.y);
|
|
ctx.closePath();
|
|
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
|
|
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
|
|
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
/** Draw rectangular conveyance (conveyor, chute, etc.) */
|
|
function drawRectConveyanceSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
|
|
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
|
|
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
|
|
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
|
|
ctx.fillRect(sym.x, sym.y, sym.w, sym.h);
|
|
ctx.strokeRect(sym.x, sym.y, sym.w, sym.h);
|
|
}
|
|
|
|
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)) {
|
|
drawSpurSymbol(ctx, sym);
|
|
} else if (isExtendoType(sym.symbolId)) {
|
|
drawExtendoSymbol(ctx, sym);
|
|
} else if (isRectConveyanceType(sym.symbolId)) {
|
|
drawRectConveyanceSymbol(ctx, sym);
|
|
} 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;
|
|
}
|
|
|
|
/** 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 = LABEL_CONFIG.padding;
|
|
|
|
// 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;
|
|
// Compute text dimensions at default fontSize to find optimal placement
|
|
ctx.font = LABEL_CONFIG.font;
|
|
const maxTextW = Math.max(...lines.map(l => ctx.measureText(l).width));
|
|
const th = LABEL_CONFIG.fontSize * lines.length;
|
|
// Push text down where trapezoid is wider, but leave room
|
|
const optCy = Math.min(sym.h - th / 2 - 1, sym.h * 0.55);
|
|
// Right edge at top of text — text must not exceed this
|
|
const textTop = optCy - th / 2;
|
|
const rightEdge = w2 + (Math.max(0, textTop) / sym.h) * (sym.w - w2);
|
|
// Align text right edge to the angled edge, clamped left at 0
|
|
cx = sym.x + Math.max(maxTextW / 2, rightEdge - maxTextW / 2);
|
|
cy = sym.y + optCy;
|
|
availW = Infinity;
|
|
availH = sym.h - pad * 2;
|
|
} 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 = LABEL_CONFIG.color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
for (const tryLines of [lines, stripped]) {
|
|
let fontSize = LABEL_CONFIG.fontSize;
|
|
const lineCount = tryLines.length;
|
|
const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1;
|
|
|
|
while (totalTextH() > availH && fontSize > LABEL_CONFIG.minFontSize) fontSize -= LABEL_CONFIG.fontSizeStep;
|
|
|
|
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
|
|
let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
|
|
while (maxW > availW && fontSize > LABEL_CONFIG.minFontSize) {
|
|
fontSize -= LABEL_CONFIG.fontSizeStep;
|
|
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
|
|
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
|
|
}
|
|
|
|
if (fontSize >= LABEL_CONFIG.minFontSize) {
|
|
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
|
|
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 = LABEL_CONFIG.color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
for (const tryLines of [lines, stripped]) {
|
|
let fontSize = LABEL_CONFIG.fontSize;
|
|
const lineCount = tryLines.length;
|
|
while (fontSize * lineCount > availW && fontSize > LABEL_CONFIG.minFontSize) fontSize -= LABEL_CONFIG.fontSizeStep;
|
|
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
|
|
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 > LABEL_CONFIG.minFontSize) {
|
|
fontSize -= LABEL_CONFIG.fontSizeStep;
|
|
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
|
|
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
|
|
}
|
|
if (fontSize >= LABEL_CONFIG.minFontSize) {
|
|
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();
|
|
}
|