igurielidze c5bb986a82 Refactor: extract render theme, hit-testing module, clean up legacy exports
- Extract 25+ hardcoded colors/sizes to render-theme.ts
- Extract pure hit-testing functions to hit-testing.ts (-104 lines from interactions.ts)
- Remove 11 legacy re-exports from symbols.ts, use config objects directly
- Fix preloadSymbolImages async/await anti-pattern
- Extract ensureEpcWaypoints() helper (3x dedup)
- Fix PDF not clearing on MCM switch
- Fix MCM persistence on reload
- Change defaults: grid off, grid size 2, min spacing 2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:03:38 +04:00

468 lines
13 KiB
Svelte

<script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js';
import { exportSVG, loadLayoutJSON } from '$lib/export.js';
import { loadPdfFile, loadPdfFromPath, pdfZoomIn, pdfZoomOut, removePdf, toggleEditBackground, restorePdf } from '$lib/pdf.js';
import { discoverProjects } from '$lib/projects.js';
import { onMount } from 'svelte';
import Palette from './Palette.svelte';
let importFileEl = $state<HTMLInputElement>(null!);
let pdfFileEl = $state<HTMLInputElement>(null!);
let toolbarCollapsed = $state(false);
let projectOpen = $state(false);
let pdfOpen = $state(false);
let settingsOpen = $state(false);
onMount(async () => {
const projects = await discoverProjects();
const savedProject = localStorage.getItem('scada_lastProject');
const savedMcm = localStorage.getItem('scada_lastMcm');
if (projects.length > 0) {
layout.projects = projects;
const proj = (savedProject && projects.find(p => p.name === savedProject)) || projects[0];
layout.currentProject = proj.name;
const mcm = (savedMcm && proj.mcms.find(m => m.name === savedMcm)) || proj.mcms[0];
if (mcm) layout.currentMcm = mcm.name;
layout.loadMcmState();
} else {
layout.currentProject = savedProject || 'default';
layout.currentMcm = savedMcm || 'MCM01';
layout.loadMcmState();
}
localStorage.setItem('scada_lastProject', layout.currentProject);
localStorage.setItem('scada_lastMcm', layout.currentMcm);
await restorePdf();
});
function onProjectChange(e: Event) {
layout.saveMcmState();
const val = (e.target as HTMLSelectElement).value;
layout.currentProject = val;
const proj = layout.projects.find(p => p.name === val);
if (proj && proj.mcms.length > 0) {
layout.currentMcm = proj.mcms[0].name;
}
localStorage.setItem('scada_lastProject', layout.currentProject);
localStorage.setItem('scada_lastMcm', layout.currentMcm);
layout.loadMcmState();
autoLoadPdf();
}
function onMcmChange(e: Event) {
layout.saveMcmState();
layout.currentMcm = (e.target as HTMLSelectElement).value;
localStorage.setItem('scada_lastMcm', layout.currentMcm);
layout.loadMcmState();
autoLoadPdf();
}
async function autoLoadPdf() {
const proj = layout.projects.find(p => p.name === layout.currentProject);
if (!proj) { removePdf(); return; }
const mcm = proj.mcms.find(m => m.name === layout.currentMcm);
if (!mcm?.pdfPath) { removePdf(); return; }
await loadPdfFromPath(mcm.pdfPath);
}
function onCanvasSizeChange() {
layout.markDirty();
layout.saveMcmState();
}
function onGridSizeChange() {
layout.markDirty();
layout.saveMcmState();
}
function onShowGridChange() {
layout.markDirty();
}
function onMinSpacingChange() {
layout.saveMcmState();
}
function clearCanvas() {
if (!confirm('Clear all placed symbols?')) return;
layout.clearAll();
}
async function onImportFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await loadLayoutJSON(file);
} catch (err) {
alert('Invalid layout file: ' + (err instanceof Error ? err.message : String(err)));
}
(e.target as HTMLInputElement).value = '';
}
async function onPdfFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
await loadPdfFile(file);
(e.target as HTMLInputElement).value = '';
}
let currentMcms = $derived(
layout.projects.find(p => p.name === layout.currentProject)?.mcms ?? []
);
</script>
<div class="toolbar" class:collapsed={toolbarCollapsed}>
<button class="collapse-btn" onclick={() => toolbarCollapsed = !toolbarCollapsed}>
{toolbarCollapsed ? '>' : '<'}
</button>
{#if !toolbarCollapsed}
<div class="toolbar-content">
<!-- Project section (collapsible) -->
<button class="section-toggle" onclick={() => projectOpen = !projectOpen}>
<span class="toggle-arrow">{projectOpen ? '\u25BC' : '\u25B6'}</span>
Project
<span class="section-summary">{layout.currentProject || '...'} / {layout.currentMcm || '...'}</span>
</button>
{#if projectOpen}
<div class="section-body">
{#if layout.projects.length > 0}
<div class="setting">
<label for="projectSelect">Project:</label>
<select id="projectSelect" value={layout.currentProject} onchange={onProjectChange}>
{#each layout.projects as proj (proj.name)}
<option value={proj.name}>{proj.name}</option>
{/each}
</select>
</div>
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
{#each currentMcms as mcm (mcm.name)}
<option value={mcm.name}>{mcm.name}</option>
{/each}
</select>
</div>
{:else}
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
<option value="MCM08">MCM08</option>
</select>
</div>
{/if}
</div>
{/if}
<!-- Background PDF section (collapsible) -->
<button class="section-toggle" onclick={() => pdfOpen = !pdfOpen}>
<span class="toggle-arrow">{pdfOpen ? '\u25BC' : '\u25B6'}</span>
Background PDF
{#if layout.pdfLoaded}
<span class="section-summary">{Math.round(layout.pdfScale * 100)}%</span>
{/if}
</button>
{#if pdfOpen}
<div class="section-body">
<div class="setting btn-row">
<button onclick={() => pdfFileEl.click()}>Load PDF</button>
<input bind:this={pdfFileEl} type="file" accept=".pdf" style="display:none" onchange={onPdfFile}>
</div>
<div class="setting btn-row">
<button onclick={pdfZoomIn}>PDF +</button>
<button onclick={pdfZoomOut}>PDF -</button>
</div>
<div class="setting btn-row">
<button
class:edit-bg-active={layout.editingBackground}
onclick={toggleEditBackground}
>
{layout.editingBackground ? 'Done' : 'Edit BG'}
</button>
<button onclick={removePdf}>Remove</button>
</div>
{#if layout.pdfLoaded}
<div class="pdf-info">
scale {Math.round(layout.pdfScale * 100)}% pos: {Math.round(layout.pdfOffsetX)},{Math.round(layout.pdfOffsetY)}
</div>
{/if}
</div>
{/if}
<!-- Settings section (collapsible) -->
<button class="section-toggle" onclick={() => settingsOpen = !settingsOpen}>
<span class="toggle-arrow">{settingsOpen ? '\u25BC' : '\u25B6'}</span>
Settings
</button>
{#if settingsOpen}
<div class="section-body">
<div class="setting canvas-size-row">
<div>
<label for="canvasW">W:</label>
<input type="number" id="canvasW" bind:value={layout.canvasW} min="800" max="7680" step="10" onchange={onCanvasSizeChange}>
</div>
<div>
<label for="canvasH">H:</label>
<input type="number" id="canvasH" bind:value={layout.canvasH} min="600" max="4320" step="10" onchange={onCanvasSizeChange}>
</div>
</div>
<div class="setting">
<label for="gridSize">Grid Size (px):</label>
<input
type="number" id="gridSize"
bind:value={layout.gridSize}
min="5" max="100" step="5"
onchange={onGridSizeChange}
>
</div>
<div class="setting">
<label for="minSpacing">Min Edge Spacing (px):</label>
<input
type="number" id="minSpacing"
bind:value={layout.minSpacing}
min="0" max="100" step="5"
onchange={onMinSpacingChange}
>
</div>
<div class="setting">
<label class="checkbox-label">
<input type="checkbox" bind:checked={layout.showGrid} onchange={onShowGridChange}>
Show Grid
</label>
</div>
<div class="setting">
<label class="checkbox-label">
<input type="checkbox" bind:checked={layout.snapEnabled}>
Snap to Grid
</label>
</div>
<div class="setting btn-row">
<button onclick={exportSVG}>Save SVG</button>
<button onclick={() => importFileEl.click()}>Load JSON</button>
<button onclick={clearCanvas}>Clear</button>
<input bind:this={importFileEl} type="file" accept=".json" style="display:none" onchange={onImportFile}>
</div>
</div>
{/if}
<!-- Symbols section (always open, takes remaining space) -->
<div class="symbols-header">Symbols</div>
<Palette />
</div>
{/if}
</div>
<style>
.toolbar {
width: 240px;
min-width: 240px;
background: #16213e;
border-right: 2px solid #0f3460;
display: flex;
flex-direction: column;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.toolbar.collapsed {
width: 32px;
min-width: 32px;
}
.collapse-btn {
position: absolute;
top: 8px;
right: 4px;
width: 24px;
height: 24px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
z-index: 10;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.collapse-btn:hover {
background: #e94560;
}
.toolbar-content {
padding: 6px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Collapsible section toggle */
.section-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 5px 6px;
margin-bottom: 2px;
background: #0d2847;
border: 1px solid #1a1a5e;
border-radius: 4px;
color: #e94560;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: background 0.15s;
text-align: left;
flex-shrink: 0;
}
.section-toggle:hover {
background: #122d52;
}
.toggle-arrow {
font-size: 8px;
width: 10px;
flex-shrink: 0;
}
.section-summary {
margin-left: auto;
font-size: 9px;
color: #667;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.section-body {
padding: 4px 2px 6px;
flex-shrink: 0;
}
/* Symbols header (always visible) */
.symbols-header {
font-size: 10px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 5px 6px 3px;
flex-shrink: 0;
}
.setting {
margin-bottom: 4px;
}
.setting label {
display: block;
font-size: 10px;
margin-bottom: 2px;
color: #8899aa;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px !important;
color: #ccc !important;
}
.setting input[type="number"],
.setting select {
width: 100%;
padding: 4px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.15s;
outline: none;
}
.setting input[type="number"]:focus,
.setting select:focus {
border-color: #e94560;
}
.setting select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238899aa'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
padding-right: 24px;
}
.setting input[type="checkbox"] {
accent-color: #e94560;
width: 13px;
height: 13px;
}
.btn-row {
display: flex;
gap: 3px;
}
.btn-row button,
.setting > button {
flex: 1;
padding: 5px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn-row button:hover,
.setting > button:hover {
background: #e94560;
border-color: #e94560;
}
.btn-row button:active,
.setting > button:active {
transform: translateY(0);
}
.edit-bg-active {
background: #e94560 !important;
border-color: #e94560 !important;
color: #fff !important;
}
.canvas-size-row {
display: flex;
gap: 4px;
}
.canvas-size-row > div {
flex: 1;
}
.pdf-info {
font-size: 9px;
color: #667;
padding: 2px 0;
}
</style>