Add device visibility controls: right-click hide and top bar type toggles

- Right-click context menu: "Hide" option to hide individual symbols
- "Show All Hidden" appears in context menu when anything is hidden
- Top visibility bar with toggle chips for each device group
- Hidden symbols are excluded from rendering, hit testing, and SVG export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-21 18:36:01 +04:00
parent cc91481c98
commit 51794cb9ae
9 changed files with 201 additions and 9 deletions

View File

@ -84,6 +84,21 @@
contextMenu = null; 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() { function handleSetLabel() {
if (!contextMenu) return; if (!contextMenu) return;
const sym = layout.symbols.find(s => s.id === contextMenu!.symId); const sym = layout.symbols.find(s => s.id === contextMenu!.symId);
@ -142,7 +157,12 @@
<button class="context-menu-item" onclick={handleSetLabel} role="menuitem">Set ID</button> <button class="context-menu-item" onclick={handleSetLabel} role="menuitem">Set ID</button>
<button class="context-menu-item" onclick={handleMirror} role="menuitem">Mirror</button> <button class="context-menu-item" onclick={handleMirror} role="menuitem">Mirror</button>
<button class="context-menu-item" onclick={handleDuplicate} role="menuitem">Duplicate</button> <button class="context-menu-item" onclick={handleDuplicate} role="menuitem">Duplicate</button>
<button class="context-menu-item" onclick={handleHide} role="menuitem">Hide</button>
<button class="context-menu-item" onclick={handleDelete} role="menuitem">Delete</button> <button class="context-menu-item" onclick={handleDelete} role="menuitem">Delete</button>
{#if layout.symbols.some(s => s.hidden) || layout.hiddenGroups.size > 0}
<div class="context-menu-divider"></div>
<button class="context-menu-item" onclick={handleShowAll} role="menuitem">Show All Hidden</button>
{/if}
</div> </div>
{/if} {/if}
@ -222,4 +242,10 @@
.context-menu-item:hover { .context-menu-item:hover {
background: #e94560; background: #e94560;
} }
.context-menu-divider {
height: 1px;
background: #0f3460;
margin: 4px 0;
}
</style> </style>

View File

@ -0,0 +1,80 @@
<script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js';
import { SYMBOL_GROUPS } from '$lib/symbols.js';
function toggle(group: string) {
layout.toggleGroupHidden(group);
}
let hasHidden = $derived(layout.symbols.some(s => s.hidden));
</script>
<div class="visibility-bar">
{#each SYMBOL_GROUPS as group}
<button
class="group-chip"
class:hidden-group={layout.hiddenGroups.has(group)}
onclick={() => toggle(group)}
title={layout.hiddenGroups.has(group) ? `Show ${group}` : `Hide ${group}`}
>
{group}
</button>
{/each}
{#if hasHidden}
<button class="show-all-btn" onclick={() => layout.showAllHidden()}>
Show Hidden
</button>
{/if}
</div>
<style>
.visibility-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #1a1a2e;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.group-chip {
padding: 3px 10px;
font-size: 11px;
border-radius: 12px;
border: 1px solid #0f3460;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.group-chip:hover {
border-color: #e94560;
}
.group-chip.hidden-group {
background: transparent;
color: #555;
border-color: #333;
text-decoration: line-through;
}
.show-all-btn {
margin-left: auto;
padding: 3px 10px;
font-size: 11px;
border-radius: 12px;
border: 1px solid #e94560;
background: transparent;
color: #e94560;
cursor: pointer;
transition: all 0.15s;
}
.show-all-btn:hover {
background: #e94560;
color: #fff;
}
</style>

View File

@ -1,6 +1,7 @@
/** Pure hit-testing functions — no module state, no side effects */ /** 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 { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js'; import type { PlacedSymbol } from '../types.js';
@ -160,6 +161,7 @@ export function hitTestSymbols(
): number | null { ): number | null {
for (let i = symbols.length - 1; i >= 0; i--) { for (let i = symbols.length - 1; i >= 0; i--) {
const sym = symbols[i]; const sym = symbols[i];
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
const local = toSymbolLocal(cx, cy, sym); const local = toSymbolLocal(cx, cy, sym);
if (pointInSymbol(local.x, local.y, sym)) return sym.id; if (pointInSymbol(local.x, local.y, sym)) return sym.id;
} }

View File

@ -1,5 +1,5 @@
import { layout } from '../stores/layout.svelte.js'; 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 { checkSpacingViolation } from './collision.js';
import { THEME } from './render-theme.js'; import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js'; import type { PlacedSymbol } from '../types.js';
@ -68,11 +68,14 @@ export function render() {
drawGrid(ctx); 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) { 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); if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
} }
for (const sym of layout.symbols) { 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); if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
} }
@ -487,15 +490,23 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
strokeOutline(ctx, sym, 0); strokeOutline(ctx, sym, 0);
} }
// Label above symbol // Label: centered inside symbol if large enough, above if too small
if (sym.label) { if (sym.label) {
ctx.fillStyle = THEME.label.color; ctx.fillStyle = THEME.label.color;
ctx.font = THEME.label.font; ctx.font = THEME.label.font;
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.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
} }
} }
}
function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save(); ctx.save();

View File

@ -1,5 +1,5 @@
import { layout } from './stores/layout.svelte.js'; 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 { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js'; import type { PlacedSymbol } from './types.js';
@ -30,6 +30,7 @@ export async function exportSVG() {
]; ];
for (const sym of layout.symbols) { for (const sym of layout.symbols) {
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
const rot = sym.rotation || 0; const rot = sym.rotation || 0;
const mirrored = sym.mirrored || false; const mirrored = sym.mirrored || false;
const cx = sym.x + sym.w / 2; const cx = sym.x + sym.w / 2;

View File

@ -35,6 +35,9 @@ class LayoutStore {
// Label-drag drop target highlight (symbol id or null) // Label-drag drop target highlight (symbol id or null)
labelDropTarget = $state<number | null>(null); labelDropTarget = $state<number | null>(null);
// Visibility: hidden device type groups and count of individually hidden symbols
hiddenGroups = $state<Set<string>>(new Set());
// Dirty flag for canvas re-render // Dirty flag for canvas re-render
dirty = $state(0); dirty = $state(0);
@ -204,6 +207,58 @@ class LayoutStore {
this.saveMcmState(); 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() { clearAll() {
this.pushUndo(); this.pushUndo();
this.symbols = []; this.symbols = [];

View File

@ -72,6 +72,11 @@ export const SYMBOLS: SymbolDef[] = [
export const SYMBOL_GROUPS = [...new Set(SYMBOLS.map(s => s.group))]; 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([ export const PRIORITY_TYPES = new Set([
'conveyor', 'conveyor_v', 'chute', 'chute_v', 'conveyor', 'conveyor_v', 'chute', 'chute_v',
'tipper', 'tipper_v', 'extendo', 'extendo_v', 'tipper', 'tipper_v', 'extendo', 'extendo_v',

View File

@ -29,6 +29,7 @@ export interface PlacedSymbol {
w2?: number; // Spur: top base width w2?: number; // Spur: top base width
rotation: number; rotation: number;
mirrored?: boolean; // Horizontal flip mirrored?: boolean; // Horizontal flip
hidden?: boolean; // Individually hidden via context menu
curveAngle?: number; // For curved conveyors/chutes curveAngle?: number; // For curved conveyors/chutes
epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords) epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords)
pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26) pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26)

View File

@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { preloadSymbolImages } from '$lib/symbols.js'; import { preloadSymbolImages } from '$lib/symbols.js';
import Toolbar from '../components/Toolbar.svelte'; import Toolbar from '../components/Toolbar.svelte';
import VisibilityBar from '../components/VisibilityBar.svelte';
import Canvas from '../components/Canvas.svelte'; import Canvas from '../components/Canvas.svelte';
import DeviceDock from '../components/DeviceDock.svelte'; import DeviceDock from '../components/DeviceDock.svelte';
@ -20,7 +21,10 @@
{#if ready} {#if ready}
<div class="app"> <div class="app">
<Toolbar /> <Toolbar />
<div class="main-area">
<VisibilityBar />
<Canvas /> <Canvas />
</div>
<DeviceDock /> <DeviceDock />
</div> </div>
{:else} {:else}
@ -37,6 +41,13 @@
overflow: hidden; overflow: hidden;
} }
.main-area {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.loading { .loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;