style: unify UI styling & restore dashboard logic

- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
This commit is contained in:
2026-06-17 12:29:26 +09:00
parent b37981506e
commit 89d3ac2e89
16 changed files with 1440 additions and 596 deletions

View File

@@ -30,9 +30,9 @@ class HwAssetModal extends BaseModal {
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left" style="display: flex; align-items: center; gap: 0.75rem;">
<div class="header-left">
<h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
<div class="category-badge-wrapper" id="hw-category-badge-container"></div>
<div id="hw-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
@@ -134,17 +134,17 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.OS.ui}</label>
<input type="text" id="hw-os" name="os" />
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.CPU.ui}</label>
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" />
<div id="hw-cpu-list" class="autocomplete-list hidden"></div>
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.RAM.ui}</label>
<input type="text" id="hw-ram" name="ram" autocomplete="off" />
<div id="hw-ram-list" class="autocomplete-list hidden"></div>
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.GPU.ui}</label>
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" />
<div id="hw-gpu-list" class="autocomplete-list hidden"></div>
@@ -165,16 +165,16 @@ class HwAssetModal extends BaseModal {
<!-- 동적 볼륨 정보 -->
<div class="form-group full-width spec-only">
<label>디스크 구성 (Volume)</label>
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm" style="margin-top: 8px;">+ 볼륨 추가</button>
<div id="hw-volume-container" class="dynamic-row-container"></div>
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm">+ 볼륨 추가</button>
</div>
<!-- [SECTION 4] 네트워크 및 원격 정보 -->
<div class="form-section-title net-only">네트워크 및 원격 관리</div>
<div class="form-group full-width net-only">
<label>접속 정보 (IP / MAC / Remote)</label>
<div id="hw-remote-info-container" style="display: flex; flex-direction: column; gap: 12px;"></div>
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm" style="margin-top: 8px;">+ 접속 정보 추가</button>
<div id="hw-remote-info-container" class="dynamic-row-container"></div>
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm">+ 접속 정보 추가</button>
</div>
<!-- [SECTION 5] 위치 정보 -->
@@ -186,7 +186,7 @@ class HwAssetModal extends BaseModal {
<div class="form-group location-field">
<label>상세 위치</label>
<div class="input-with-btn">
<select id="hw-location_detail" name="location_detail" style="flex: 1;">
<select id="hw-location_detail" name="location_detail">
<option value="">층을 먼저 선택하세요</option>
</select>
<button type="button" id="btn-reg-loc-map" class="btn btn-outline hidden">위치 등록</button>
@@ -216,9 +216,9 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui}</label>
<div class="input-with-btn">
<input type="hidden" id="hw-approval_document" name="approval_document" />
<div id="hw-file-name-display" class="is-readonly-field" style="flex: 1; border: 1px solid var(--hairline); border-radius: 6px; padding: 0 12px; height: clamp(34px, 4.5vmin, 44px); display: flex; align-items: center; font-size: var(--fs-sm); color: var(--mute);">파일 선택...</div>
<div id="hw-file-name-display" class="file-upload-display">파일 선택...</div>
<div id="hw-file-link-container"></div>
<input type="file" id="hw-approval_document_file" style="display: none;" />
<input type="file" id="hw-approval_document_file" class="hidden" />
<button type="button" id="btn-file-select" class="btn btn-outline">파일 찾기</button>
</div>
</div>
@@ -247,34 +247,6 @@ class HwAssetModal extends BaseModal {
</div>
</div>
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
.hidden {
display: none !important;
}
@@ -303,13 +275,16 @@ class HwAssetModal extends BaseModal {
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
this.applyRoleVisibility();
const badgeContainer = document.getElementById('hw-category-badge-container');
if (badgeContainer) {
badgeContainer.innerHTML = cat ? `<span class="badge badge-primary">${cat}</span>` : '';
const identityContainer = document.getElementById('hw-header-identity');
if (identityContainer) {
identityContainer.innerHTML = cat ? `<span class="service-type-badge">${cat}</span>` : '';
}
});
typeSelect.addEventListener('change', () => this.applyRoleVisibility());
typeSelect.addEventListener('change', () => {
this.applyRoleVisibility();
this.updateHeaderIdentity(this.currentAsset);
});
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
@@ -439,22 +414,21 @@ class HwAssetModal extends BaseModal {
const container = document.getElementById('hw-volume-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'volume-row flex items-center gap-2';
const inputStyle = 'height: clamp(34px, 4.5vmin, 44px) !important; box-sizing: border-box !important; font-size: var(--fs-sm); margin: 0; padding: 0 8px;';
row.className = 'volume-row items-center';
row.innerHTML = `
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}>
<select class="vol-type" style="width: 80px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
</select>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" style="${inputStyle} width: 70px;" ${!this.isEditMode ? 'disabled' : ''}>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="flex: 1; min-width: 0;" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" style="width: 70px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
</select>
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: clamp(34px, 4.5vmin, 44px) !important; padding: 0 12px; color: var(--danger); border-color: var(--danger); display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
`;
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
container.appendChild(row);
}
@@ -475,34 +449,32 @@ class HwAssetModal extends BaseModal {
}
const row = document.createElement('div');
row.className = 'remote-info-row flex-col gap-1 w-full';
const baseStyle = 'height: clamp(34px, 4.5vmin, 44px) !important; box-sizing: border-box !important; margin: 0;';
const compactStyle = `${baseStyle} font-size: var(--fs-xs); padding: 0 6px;`;
row.className = 'remote-info-row w-full';
const line1 = document.createElement('div');
line1.className = 'ri-line flex items-center gap-1.5';
line1.className = 'ri-line items-center';
line1.innerHTML = `
<select class="ri-type" ${!this.isEditMode ? 'disabled' : ''} style="${compactStyle} width: 75px; flex-shrink: 0;">
<select class="ri-type" ${!this.isEditMode ? 'disabled' : ''} style="width: 75px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
<option value="IP" ${info.type === 'IP' ? 'selected' : ''}>IP 주소</option>
<option value="MAC" ${info.type === 'MAC' ? 'selected' : ''}>MAC 주소</option>
</select>
<input type="text" class="ri-val1" value="${info.val1 || ''}" placeholder="주소 입력" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<button type="button" class="btn-outline btn-remove-row ri-remove-btn edit-only-btn" style="height: clamp(34px, 4.5vmin, 44px) !important; padding: 0 10px; color: var(--danger); border-color: var(--danger); flex-shrink: 0; display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
<input type="text" class="ri-val1" value="${info.val1 || ''}" placeholder="주소 입력" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
`;
const line2 = document.createElement('div');
line2.className = 'ri-line ri-cred-line flex items-center gap-1.5';
line2.className = 'ri-line ri-cred-line items-center';
if (info.type !== 'IP') line2.classList.add('hidden');
line2.innerHTML = `
<div class="ri-connector" style="width: 16px; border-left: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); height: 16px; margin-left: 10px; margin-top: -12px; flex-shrink: 0;"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''} style="${compactStyle} width: 85px; flex-shrink: 0;">
<div class="ri-connector" style="width: 16px; height: 16px; margin-top: -12px;"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''} style="width: 85px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
<option value="원격접속" ${info.name === '원격접속' ? 'selected' : ''}>원격접속</option>
<option value="리눅스" ${info.name === '리눅스' ? 'selected' : ''}>리눅스</option>
<option value="기타" ${info.name === '기타' ? 'selected' : ''}>기타</option>
</select>
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
`;
row.appendChild(line1);
@@ -518,7 +490,7 @@ class HwAssetModal extends BaseModal {
}
});
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
container.appendChild(row);
}
@@ -633,11 +605,7 @@ class HwAssetModal extends BaseModal {
this.renderHistory(asset.id);
this.applyRoleVisibility();
this.updatePcGradeBadge();
const badgeContainer = document.getElementById('hw-category-badge-container');
if (badgeContainer) {
badgeContainer.innerHTML = asset.category ? `<span class="badge badge-primary">${asset.category}</span>` : '';
}
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -647,6 +615,29 @@ class HwAssetModal extends BaseModal {
this.toggleEditOnlyBtns(mode !== 'view');
this.updateMapButtonVisibility();
this.applyRoleVisibility();
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('hw-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || asset.category || '';
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || asset.asset_type || '';
const code = (document.getElementById('hw-asset_code') as HTMLInputElement)?.value || asset.asset_code || '미부여';
const serviceType = (document.getElementById('hw-service_type') as HTMLSelectElement)?.value || asset.service_type || '외부';
container.innerHTML = `
<span class="asset-code-title">${code}</span>
<span class="service-type-badge">${serviceType}</span>
<span class="service-type-badge" style="background: var(--canvas-soft-2); color: var(--primary); border: 1px solid var(--hairline);">${category}</span>
<span class="asset-type-label">${type}</span>
`;
}
private toggleFileUploadUI(showUpload: boolean) {
@@ -723,40 +714,115 @@ class HwAssetModal extends BaseModal {
overlay.className = 'image-picker-overlay';
const renderContent = () => {
const imgPath = imagePaths[currentIdx];
const digitalMap = this.generateDynamicSVG(imgPath);
const isMulti = imagePaths.length > 1;
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
overlay.innerHTML = `
<div class="image-picker-window">
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker">&times;</button></div>
<div class="image-picker-header">
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
<button class="btn-icon btn-close-picker" aria-label="닫기">&times;</button>
</div>
<div class="image-picker-content">
${isMulti ? `
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">&#10094;</div>
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">&#10095;</div>
` : ''}
<div class="layout-map-container" id="picker-container">
<div class="image-marker-wrapper" style="position: relative; display: inline-block;">
<img src="${imgPath}" class="layout-map-img" style="display: block; max-width: 100%; max-height: 70vh;" />
<div id="picker-marker" class="layout-marker hidden"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>
${isHtmlMap
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<div class="image-marker-wrapper">
<img src="${imgPath}" class="layout-map-img" />
<div id="picker-marker" class="layout-marker hidden"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>
</div>`;
let selectedX = ''; let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`;
marker.classList.remove('hidden');
});
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility(); overlay.remove();
});
if (isMulti) {
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
}
if (isHtmlMap) {
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'PICK_LOCATION') {
selectedX = e.data.x;
selectedY = e.data.y;
}
};
window.addEventListener('message', handleMessage);
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility();
window.removeEventListener('message', handleMessage);
overlay.remove();
});
} else {
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rectBound = container.getBoundingClientRect();
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
let snapped = false;
overlay.querySelectorAll('rect').forEach(rect => {
const rx = parseFloat(rect.getAttribute('x') || '0');
const ry = parseFloat(rect.getAttribute('y') || '0');
const rw = parseFloat(rect.getAttribute('width') || '0');
const rh = parseFloat(rect.getAttribute('height') || '0');
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
overlay.querySelectorAll('rect').forEach(r => {
r.style.fill = 'rgba(30,81,73,0.05)';
r.style.stroke = 'rgba(30,81,73,0.2)';
r.style.strokeWidth = '0.2';
});
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
rect.style.stroke = '#FF3D00';
rect.style.strokeWidth = '0.8';
selectedX = rx.toFixed(2);
selectedY = ry.toFixed(2);
marker.style.left = `${rx + rw/2}%`;
marker.style.top = `${ry + rh/2}%`;
marker.classList.remove('hidden');
snapped = true;
}
});
if (!snapped) {
selectedX = '';
selectedY = '';
marker.classList.add('hidden');
overlay.querySelectorAll('rect').forEach(r => {
r.style.fill = 'rgba(30,81,73,0.05)';
r.style.stroke = 'rgba(30,81,73,0.2)';
r.style.strokeWidth = '0.2';
});
}
});
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility(); overlay.remove();
});
}
};
renderContent(); document.body.appendChild(overlay);
}
@@ -770,27 +836,34 @@ class HwAssetModal extends BaseModal {
overlay.innerHTML = `
<div class="image-picker-window">
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker">&times;</button></div>
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker" aria-label="닫기">&times;</button></div>
<div class="image-picker-content">
<div class="layout-map-container readonly">
<div class="image-marker-wrapper" style="position: relative; display: inline-block;">
<img src="${imagePath}" class="layout-map-img" style="display: block; max-width: 100%; max-height: 70vh;" />
<div class="digital-overlay-layer">${digitalMap}</div>
</div>
${isHtmlMap
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<div class="image-marker-wrapper">
<img src="${imagePath}" class="layout-map-img" />
<div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
</div>`;
document.body.appendChild(overlay);
if (!isHtmlMap && digitalMap) {
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
overlay.querySelectorAll('rect').forEach(rect => {
const sx = parseFloat(rect.getAttribute('x') || '0');
const sy = parseFloat(rect.getAttribute('y') || '0');
if (Math.abs(sx - curX) < 0.1 && Math.abs(sy - curY) < 0.1) {
rect.style.fill = 'rgba(255, 61, 0, 0.5)';
rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '1.2';
rect.style.filter = 'drop-shadow(0 0 6px rgba(255, 61, 0, 0.8))';
if (Math.abs(sx - curX) < 0.01 && Math.abs(sy - curY) < 0.01) {
rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8';
const w = parseFloat(rect.getAttribute('width') || '0');
const h = parseFloat(rect.getAttribute('height') || '0');
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
if (marker) { marker.style.left = `${sx + w/2}%`; marker.style.top = `${sy + h/2}%`; }
}
});
}