From 93afd0a554b55df957d3335ea7be95d394eab35f Mon Sep 17 00:00:00 2001 From: igurielidze Date: Sat, 21 Mar 2026 17:40:08 +0400 Subject: [PATCH] Add device dock search, IDs drag-to-assign tab, and label drop-target highlight - Add search filter to Devices tab in right dock - Add IDs tab: flat list of unassigned device IDs, drag onto placed symbol to assign label - Highlight drop target symbol with cyan glow during label drag - Add labelDropTarget state and dropTarget theme entry Co-Authored-By: Claude Opus 4.6 (1M context) --- svelte-app/src/components/DeviceDock.svelte | 289 +++++++++++++++++--- svelte-app/src/lib/canvas/hit-testing.ts | 40 ++- svelte-app/src/lib/canvas/interactions.ts | 108 ++++++-- svelte-app/src/lib/canvas/render-theme.ts | 31 ++- svelte-app/src/lib/canvas/renderer.ts | 36 ++- svelte-app/src/lib/export.ts | 28 +- svelte-app/src/lib/stores/layout.svelte.ts | 3 + 7 files changed, 449 insertions(+), 86 deletions(-) diff --git a/svelte-app/src/components/DeviceDock.svelte b/svelte-app/src/components/DeviceDock.svelte index 9cf4bf8..ca71ab4 100644 --- a/svelte-app/src/components/DeviceDock.svelte +++ b/svelte-app/src/components/DeviceDock.svelte @@ -1,7 +1,7 @@
@@ -71,42 +100,102 @@ {#if !collapsed}
-
- Devices - {totalAll - totalCount}/{totalAll} + +
+ +
- {#if totalCount === 0} -
All placed
- {/if} + + {#if activeTab === 'devices'} +
+ {totalAll - totalCount}/{totalAll} placed +
-
- {#each svgGroups as [groupName, devices] (groupName)} - - {#if !collapsedZones.has(groupName)} -
- {#each devices as device (device.id)} - - {/each} -
+ +
+ + {#if filteredDevices.length === 0} +
{searchQuery ? 'No matches' : 'All placed'}
+ {/if} + +
+ {#each svgGroups as [groupName, devices] (groupName)} + + {#if !collapsedZones.has(groupName)} +
+ {#each devices as device (device.id)} + + {/each} +
+ {/if} + {/each} +
+ + + {:else} +
+ {unassignedIds.length} unassigned +
+ + + +
Drag onto a symbol to assign
+ + {#if unassignedIds.length === 0} +
{idSearchQuery ? 'No matches' : 'All IDs assigned'}
+ {/if} + +
+ {#each unassignedIds as device (device.id)} + + {/each} +
+ {/if}
{/if}
@@ -154,6 +243,7 @@ .dock-content { padding: 6px; + padding-top: 38px; overflow: hidden; flex: 1; display: flex; @@ -161,16 +251,45 @@ min-height: 0; } + /* Tabs */ + .tabs { + display: flex; + gap: 2px; + flex-shrink: 0; + margin-bottom: 4px; + } + + .tab { + flex: 1; + padding: 5px 0; + background: #0d2847; + border: 1px solid #1a1a5e; + border-radius: 4px 4px 0 0; + color: #667; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.15s; + } + + .tab:hover { + color: #aaa; + } + + .tab.active { + background: #0f3460; + color: #e94560; + border-bottom-color: #0f3460; + } + .dock-header { font-size: 10px; - color: #e94560; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: 600; - padding: 5px 6px 6px 30px; + padding: 2px 6px 4px; flex-shrink: 0; display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; } @@ -180,6 +299,57 @@ font-weight: 400; } + /* Search */ + .search-box { + position: relative; + flex-shrink: 0; + margin-bottom: 4px; + } + + .search-box input { + width: 100%; + padding: 5px 22px 5px 8px; + background: #0f3460; + border: 1px solid #1a1a5e; + color: #e0e0e0; + border-radius: 4px; + font-size: 11px; + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; + } + + .search-box input:focus { + border-color: #e94560; + } + + .search-box input::placeholder { + color: #556; + } + + .clear-search { + position: absolute; + right: 3px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + padding: 0; + background: none; + border: none; + color: #667; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + } + + .clear-search:hover { + color: #e94560; + } + .dock-empty { font-size: 11px; color: #556; @@ -286,4 +456,43 @@ overflow: hidden; text-overflow: ellipsis; } + + /* IDs tab */ + .ids-hint { + font-size: 9px; + color: #556; + padding: 0 6px 4px; + flex-shrink: 0; + } + + .id-drag-item { + display: block; + width: 100%; + padding: 4px 8px; + margin-bottom: 1px; + background: #0f3460; + border: 1px solid #1a1a5e; + border-radius: 3px; + cursor: grab; + user-select: none; + transition: all 0.15s ease; + color: #e0e0e0; + font-size: 10px; + font-weight: 500; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .id-drag-item:hover { + background: #1a1a5e; + border-color: #e94560; + transform: translateX(-2px); + } + + .id-drag-item:active { + cursor: grabbing; + transform: scale(0.98); + } diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts index 7f31ad8..ae41b50 100644 --- a/svelte-app/src/lib/canvas/hit-testing.ts +++ b/svelte-app/src/lib/canvas/hit-testing.ts @@ -4,15 +4,19 @@ import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_ import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; -/** Transform a point into a symbol's unrotated local space */ +/** Transform a point into a symbol's unrotated, unmirrored local space */ export function toSymbolLocal(px: number, py: number, sym: PlacedSymbol): { x: number; y: number } { - if (!sym.rotation) return { x: px, y: py }; + if (!sym.rotation && !sym.mirrored) return { x: px, y: py }; const scx = sym.x + sym.w / 2; const scy = sym.y + sym.h / 2; - const rad = (-sym.rotation * Math.PI) / 180; + const rad = (-(sym.rotation || 0) * Math.PI) / 180; const cos = Math.cos(rad), sin = Math.sin(rad); const dx = px - scx, dy = py - scy; - return { x: dx * cos - dy * sin + scx, y: dx * sin + dy * cos + scy }; + // Inverse of render transform: un-rotate then un-mirror + let rx = dx * cos - dy * sin; + const ry = dx * sin + dy * cos; + if (sym.mirrored) rx = -rx; + return { x: rx + scx, y: ry + scy }; } export function pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean { @@ -56,11 +60,37 @@ export function hitEpcSegmentMidpoint(cx: number, cy: number, sym: PlacedSymbol) return -1; } +/** Find nearest EPC segment to a point. Returns insert index or -1. + * Used for right-click add-waypoint — matches any point near a segment, not just midpoints. */ +export function hitEpcSegmentNearest(cx: number, cy: number, sym: PlacedSymbol): number { + if (!isEpcType(sym.symbolId)) return -1; + const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; + const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); + const maxDist = 12; + let bestIdx = -1; + let bestDist = maxDist; + for (let i = 0; i < waypoints.length - 1; i++) { + const ax = sym.x + waypoints[i].x, ay = sym.y + waypoints[i].y; + const bx = sym.x + waypoints[i + 1].x, by = sym.y + waypoints[i + 1].y; + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 === 0) continue; + const t = Math.max(0, Math.min(1, ((lx - ax) * dx + (ly - ay) * dy) / len2)); + const nx = ax + t * dx, ny = ay + t * dy; + const dist = Math.sqrt((lx - nx) ** 2 + (ly - ny) ** 2); + if (dist < bestDist) { + bestDist = dist; + bestIdx = i + 1; + } + } + return bestIdx; +} + /** Hit test resize handles. Caller must verify single-selection. */ export function hitResizeHandle(cx: number, cy: number, sym: PlacedSymbol): 'left' | 'right' | 'spur-top' | 'spur-bottom' | null { if (!isResizable(sym.symbolId)) return null; - const hs = THEME.resizeHandle.size; + const hs = THEME.resizeHandle.hitSize; const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym); if (isCurvedType(sym.symbolId)) { diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts index f8ea1f7..6895ba0 100644 --- a/svelte-app/src/lib/canvas/interactions.ts +++ b/svelte-app/src/lib/canvas/interactions.ts @@ -3,7 +3,7 @@ import { isCurvedType, isEpcType, SYMBOLS, EPC_CONFIG } from '../symbols.js'; import type { EpcWaypoint } from '../types.js'; import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js'; import { orientedBoxCorners, pastDragThreshold } from './geometry.js'; -import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitResizeHandle, hitTestSymbols } from './hit-testing.js'; +import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitEpcSegmentNearest, hitResizeHandle, hitTestSymbols } from './hit-testing.js'; /** Ensure an EPC symbol has its waypoints array initialized */ function ensureEpcWaypoints(sym: { epcWaypoints?: import('../types.js').EpcWaypoint[] }) { @@ -22,7 +22,7 @@ const CONVEYOR_MIN_W = 40; const DRAG_THRESHOLD = 4; // pixels before drag actually starts interface DragState { - type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf'; + type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf' | 'assign-label'; placedId?: number; offsetX?: number; offsetY?: number; @@ -254,6 +254,31 @@ function onCanvasMousedown(e: MouseEvent) { function onContextMenu(e: MouseEvent) { e.preventDefault(); const pos = screenToCanvas(e.clientX, e.clientY); + + // Right-click on selected EPC: add waypoint at nearest segment + if (layout.selectedIds.size === 1) { + const selId = [...layout.selectedIds][0]; + const sel = layout.symbols.find(s => s.id === selId); + if (sel && isEpcType(sel.symbolId)) { + const insertIdx = hitEpcSegmentNearest(pos.x, pos.y, sel); + if (insertIdx >= 0) { + layout.pushUndo(); + ensureEpcWaypoints(sel); + const prev = sel.epcWaypoints![insertIdx - 1]; + const next = sel.epcWaypoints![insertIdx]; + const local = toSymbolLocal(pos.x, pos.y, sel); + sel.epcWaypoints!.splice(insertIdx, 0, { + x: local.x - sel.x, + y: local.y - sel.y, + }); + recalcEpcBounds(sel); + layout.markDirty(); + layout.saveMcmState(); + return; + } + } + } + const hitId = hitTest(pos.x, pos.y); if (hitId !== null && contextMenuCallback) { contextMenuCallback(e.clientX, e.clientY, hitId); @@ -419,38 +444,14 @@ function onMousemove(e: MouseEvent) { let localX = local.x - sym.x; let localY = local.y - sym.y; - // Snap to grid (in local coords, grid aligns with symbol origin in world) + // Snap to grid if (layout.snapEnabled && !e.ctrlKey) { - // Snap the world position, then convert back to local const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize; const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize; localX = worldSnappedX - sym.x; localY = worldSnappedY - sym.y; } - // Constrain to 0/45/90 degree angles relative to adjacent waypoint - const anchor = wpIdx > 0 ? wps[wpIdx - 1] : (wpIdx < wps.length - 1 ? wps[wpIdx + 1] : null); - if (anchor) { - const dx = localX - anchor.x; - const dy = localY - anchor.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 0.1) { - // Snap angle to nearest 15-degree increment - const rawAngle = Math.atan2(dy, dx); - const snappedAngle = Math.round(rawAngle / (Math.PI / 12)) * (Math.PI / 12); - localX = anchor.x + dist * Math.cos(snappedAngle); - localY = anchor.y + dist * Math.sin(snappedAngle); - - // Re-snap to grid after angle constraint - if (layout.snapEnabled && !e.ctrlKey) { - const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize; - const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize; - localX = worldSnappedX - sym.x; - localY = worldSnappedY - sym.y; - } - } - } - wps[wpIdx] = { x: localX, y: localY }; // Recalculate bounding box recalcEpcBounds(sym); @@ -463,6 +464,17 @@ function onMousemove(e: MouseEvent) { dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px'; } + if (dragState.type === 'assign-label' && dragState.ghost) { + dragState.ghost.style.left = (e.clientX + 8) + 'px'; + dragState.ghost.style.top = (e.clientY - 10) + 'px'; + const pos = screenToCanvas(e.clientX, e.clientY); + const hoverId = hitTest(pos.x, pos.y); + if (layout.labelDropTarget !== hoverId) { + layout.labelDropTarget = hoverId; + layout.markDirty(); + } + } + if (dragState.type === 'move' && dragState.placedId != null) { const pos = screenToCanvas(e.clientX, e.clientY); if (!dragState.dragActivated) { @@ -544,6 +556,24 @@ function onMouseup(e: MouseEvent) { return; } + if (dragState.type === 'assign-label' && dragState.ghost && dragState.deviceLabel) { + dragState.ghost.remove(); + layout.labelDropTarget = null; + const pos = screenToCanvas(e.clientX, e.clientY); + const hitId = hitTest(pos.x, pos.y); + if (hitId !== null) { + const sym = layout.symbols.find(s => s.id === hitId); + if (sym) { + layout.pushUndo(); + sym.label = dragState.deviceLabel; + layout.markDirty(); + layout.saveMcmState(); + } + } + dragState = null; + return; + } + if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) { dragState.ghost.remove(); const sym = dragState.symbolDef; @@ -679,3 +709,27 @@ export function startPaletteDrag(e: MouseEvent, symbolDef: (typeof SYMBOLS)[numb dragState = { type: 'palette', symbolDef, ghost, deviceLabel }; } + +// Called from DeviceDock IDs tab to drag a label onto a placed symbol +export function startLabelDrag(e: MouseEvent, label: string) { + const ghost = document.createElement('div'); + ghost.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 9999; + background: #e94560; + color: #fff; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + opacity: 0.85; + `; + ghost.textContent = label; + ghost.style.left = (e.clientX + 8) + 'px'; + ghost.style.top = (e.clientY - 10) + 'px'; + document.body.appendChild(ghost); + + dragState = { type: 'assign-label', ghost, deviceLabel: label }; +} diff --git a/svelte-app/src/lib/canvas/render-theme.ts b/svelte-app/src/lib/canvas/render-theme.ts index f8c873d..b89c7d8 100644 --- a/svelte-app/src/lib/canvas/render-theme.ts +++ b/svelte-app/src/lib/canvas/render-theme.ts @@ -7,19 +7,29 @@ export const THEME = { }, selection: { strokeColor: '#00ff88', - lineWidth: 2, - shadowColor: 'rgba(0, 255, 136, 0.4)', - shadowBlur: 6, + lineWidth: 0.5, + pad: 1, + shadowColor: 'rgba(0, 255, 136, 0.3)', + shadowBlur: 2, }, collision: { strokeColor: '#ff0000', - lineWidth: 2, - shadowColor: 'rgba(255, 0, 0, 0.6)', - shadowBlur: 8, + lineWidth: 0.5, + pad: 1, + shadowColor: 'rgba(255, 0, 0, 0.4)', + shadowBlur: 3, }, hover: { - strokeColor: 'rgba(233, 69, 96, 0.3)', - lineWidth: 1, + strokeColor: 'rgba(233, 69, 96, 0.2)', + lineWidth: 0.5, + }, + dropTarget: { + strokeColor: '#00ccff', + lineWidth: 1.5, + pad: 3, + shadowColor: 'rgba(0, 204, 255, 0.5)', + shadowBlur: 6, + fillColor: 'rgba(0, 204, 255, 0.08)', }, label: { color: '#e94560', @@ -27,10 +37,11 @@ export const THEME = { offsetY: -3, }, resizeHandle: { - size: 10, + size: 6, + hitSize: 14, fillColor: '#00ff88', strokeColor: '#009955', - lineWidth: 1, + lineWidth: 0.5, }, epcWaypoint: { size: 6, diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index 18b2a36..e263975 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -407,6 +407,20 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole 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 = '#000000'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 0.5; + ctx.fill(); + ctx.stroke(); } else { const img = getSymbolImage(sym.file); if (!img) return false; @@ -429,7 +443,7 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.lineWidth = THEME.selection.lineWidth; ctx.shadowColor = THEME.selection.shadowColor; ctx.shadowBlur = THEME.selection.shadowBlur; - strokeOutline(ctx, sym, 2); + strokeOutline(ctx, sym, THEME.selection.pad); ctx.shadowBlur = 0; if (layout.selectedIds.size === 1) { if (isEpcType(sym.symbolId)) { @@ -446,12 +460,28 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { ctx.lineWidth = THEME.collision.lineWidth; ctx.shadowColor = THEME.collision.shadowColor; ctx.shadowBlur = THEME.collision.shadowBlur; - strokeOutline(ctx, sym, 2); + 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) { + if (!isSelected && layout.labelDropTarget !== sym.id) { ctx.strokeStyle = THEME.hover.strokeColor; ctx.lineWidth = THEME.hover.lineWidth; strokeOutline(ctx, sym, 0); diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index 9e8f351..0a517a0 100644 --- a/svelte-app/src/lib/export.ts +++ b/svelte-app/src/lib/export.ts @@ -1,5 +1,5 @@ import { layout } from './stores/layout.svelte.js'; -import { isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from './symbols.js'; +import { isEpcType, isInductionType, isSpurType, isCurvedType, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from './symbols.js'; import { deserializeSymbol } from './serialization.js'; import type { PlacedSymbol } from './types.js'; @@ -112,6 +112,32 @@ export async function exportSVG() { lines.push(` `); lines.push(` `); lines.push(' '); + } 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; + // Outer arc: from angle=0 to angle=-sweep (CCW in SVG = large-arc with sweep-flag 0) + 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(` `); + lines.push(` `); + lines.push(' '); + } else if (isSpurType(sym.symbolId)) { + const w2 = sym.w2 ?? sym.w; + const points = `${sym.x},${sym.y} ${sym.x + w2},${sym.y} ${sym.x + sym.w},${sym.y + sym.h} ${sym.x},${sym.y + sym.h}`; + lines.push(` `); + lines.push(` `); + lines.push(' '); } else { try { const svgText = await (await fetch(sym.file)).text(); diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts index 9bfbea6..2352bed 100644 --- a/svelte-app/src/lib/stores/layout.svelte.ts +++ b/svelte-app/src/lib/stores/layout.svelte.ts @@ -32,6 +32,9 @@ class LayoutStore { pdfLoaded = $state(false); editingBackground = $state(false); + // Label-drag drop target highlight (symbol id or null) + labelDropTarget = $state(null); + // Dirty flag for canvas re-render dirty = $state(0);