diff --git a/svelte-app/src/components/Canvas.svelte b/svelte-app/src/components/Canvas.svelte index 15e9a54..dbcf9b8 100644 --- a/svelte-app/src/components/Canvas.svelte +++ b/svelte-app/src/components/Canvas.svelte @@ -84,6 +84,21 @@ contextMenu = null; } + function handleHide() { + if (!contextMenu) return; + if (layout.selectedIds.has(contextMenu.symId) && layout.selectedIds.size > 1) { + layout.hideSelected(); + } else { + layout.toggleSymbolHidden(contextMenu.symId); + } + contextMenu = null; + } + + function handleShowAll() { + layout.showAllHidden(); + contextMenu = null; + } + function handleSetLabel() { if (!contextMenu) return; const sym = layout.symbols.find(s => s.id === contextMenu!.symId); @@ -142,7 +157,12 @@ + + {#if layout.symbols.some(s => s.hidden) || layout.hiddenGroups.size > 0} +
+ + {/if} {/if} @@ -222,4 +242,10 @@ .context-menu-item:hover { background: #e94560; } + + .context-menu-divider { + height: 1px; + background: #0f3460; + margin: 4px 0; + } diff --git a/svelte-app/src/components/VisibilityBar.svelte b/svelte-app/src/components/VisibilityBar.svelte new file mode 100644 index 0000000..e68a5da --- /dev/null +++ b/svelte-app/src/components/VisibilityBar.svelte @@ -0,0 +1,80 @@ + + +
+ {#each SYMBOL_GROUPS as group} + + {/each} + {#if hasHidden} + + {/if} +
+ + diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts index 32c875b..9efcb97 100644 --- a/svelte-app/src/lib/canvas/hit-testing.ts +++ b/svelte-app/src/lib/canvas/hit-testing.ts @@ -1,6 +1,7 @@ /** Pure hit-testing functions — no module state, no side effects */ -import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from '../symbols.js'; +import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from '../symbols.js'; +import { layout } from '../stores/layout.svelte.js'; import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; @@ -160,6 +161,7 @@ export function hitTestSymbols( ): number | null { for (let i = symbols.length - 1; i >= 0; i--) { const sym = symbols[i]; + if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue; const local = toSymbolLocal(cx, cy, sym); if (pointInSymbol(local.x, local.y, sym)) return sym.id; } diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index e263975..549a78a 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -1,5 +1,5 @@ import { layout } from '../stores/layout.svelte.js'; -import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveGeometry, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js'; +import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js'; import { checkSpacingViolation } from './collision.js'; import { THEME } from './render-theme.js'; import type { PlacedSymbol } from '../types.js'; @@ -68,11 +68,14 @@ export function render() { drawGrid(ctx); } - // Draw non-overlay symbols first, then overlay symbols (photoeyes) on top + // 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); } @@ -487,13 +490,21 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { strokeOutline(ctx, sym, 0); } - // Label above symbol + // Label: centered inside symbol if large enough, above if too small if (sym.label) { ctx.fillStyle = THEME.label.color; ctx.font = THEME.label.font; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); + const metrics = ctx.measureText(sym.label); + const textH = 10; // 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); + } else { + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); + } } } diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index 945bc98..c341051 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, isSpurType, isCurvedType, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from './symbols.js'; +import { isEpcType, isInductionType, isSpurType, isCurvedType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from './symbols.js'; import { deserializeSymbol } from './serialization.js'; import type { PlacedSymbol } from './types.js'; @@ -30,6 +30,7 @@ export async function exportSVG() { ]; for (const sym of layout.symbols) { + if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue; const rot = sym.rotation || 0; const mirrored = sym.mirrored || false; const cx = sym.x + sym.w / 2; diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts index 2352bed..d8d99ae 100644 --- a/svelte-app/src/lib/stores/layout.svelte.ts +++ b/svelte-app/src/lib/stores/layout.svelte.ts @@ -35,6 +35,9 @@ class LayoutStore { // Label-drag drop target highlight (symbol id or null) labelDropTarget = $state(null); + // Visibility: hidden device type groups and count of individually hidden symbols + hiddenGroups = $state>(new Set()); + // Dirty flag for canvas re-render dirty = $state(0); @@ -204,6 +207,58 @@ class LayoutStore { this.saveMcmState(); } + toggleSymbolHidden(id: number) { + const sym = this.symbols.find(s => s.id === id); + if (!sym) return; + sym.hidden = !sym.hidden; + if (this.selectedIds.has(id)) { + const newSet = new Set(this.selectedIds); + newSet.delete(id); + this.selectedIds = newSet; + } + this.markDirty(); + this.saveMcmState(); + } + + hideSelected() { + if (this.selectedIds.size === 0) return; + for (const id of this.selectedIds) { + const sym = this.symbols.find(s => s.id === id); + if (sym) sym.hidden = true; + } + this.selectedIds = new Set(); + this.markDirty(); + this.saveMcmState(); + } + + showAllHidden() { + let changed = false; + for (const sym of this.symbols) { + if (sym.hidden) { + sym.hidden = false; + changed = true; + } + } + if (this.hiddenGroups.size > 0) { + this.hiddenGroups = new Set(); + changed = true; + } + if (changed) { + this.markDirty(); + } + } + + toggleGroupHidden(group: string) { + const newSet = new Set(this.hiddenGroups); + if (newSet.has(group)) { + newSet.delete(group); + } else { + newSet.add(group); + } + this.hiddenGroups = newSet; + this.markDirty(); + } + clearAll() { this.pushUndo(); this.symbols = []; diff --git a/svelte-app/src/lib/symbols.ts b/svelte-app/src/lib/symbols.ts index 053e480..0dc0aa6 100644 --- a/svelte-app/src/lib/symbols.ts +++ b/svelte-app/src/lib/symbols.ts @@ -72,6 +72,11 @@ export const SYMBOLS: SymbolDef[] = [ export const SYMBOL_GROUPS = [...new Set(SYMBOLS.map(s => s.group))]; +const SYMBOL_GROUP_MAP = new Map(SYMBOLS.map(s => [s.id, s.group])); +export function getSymbolGroup(symbolId: string): string { + return SYMBOL_GROUP_MAP.get(symbolId) || ''; +} + export const PRIORITY_TYPES = new Set([ 'conveyor', 'conveyor_v', 'chute', 'chute_v', 'tipper', 'tipper_v', 'extendo', 'extendo_v', diff --git a/svelte-app/src/lib/types.ts b/svelte-app/src/lib/types.ts index be824bd..d8fbe14 100644 --- a/svelte-app/src/lib/types.ts +++ b/svelte-app/src/lib/types.ts @@ -29,6 +29,7 @@ export interface PlacedSymbol { w2?: number; // Spur: top base width rotation: number; mirrored?: boolean; // Horizontal flip + hidden?: boolean; // Individually hidden via context menu curveAngle?: number; // For curved conveyors/chutes epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords) pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26) diff --git a/svelte-app/src/routes/+page.svelte b/svelte-app/src/routes/+page.svelte index 4488c68..32ead0b 100644 --- a/svelte-app/src/routes/+page.svelte +++ b/svelte-app/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { preloadSymbolImages } from '$lib/symbols.js'; import Toolbar from '../components/Toolbar.svelte'; + import VisibilityBar from '../components/VisibilityBar.svelte'; import Canvas from '../components/Canvas.svelte'; import DeviceDock from '../components/DeviceDock.svelte'; @@ -20,7 +21,10 @@ {#if ready}
- +
+ + +
{:else} @@ -37,6 +41,13 @@ overflow: hidden; } + .main-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + .loading { display: flex; flex-direction: column;