Files
dwg-dxf-viewer-sample/src/layerPanel.ts
T
minsungandClaude Opus 5 3480945b5a perf(viewer2d): toggle layers by visibility instead of reloading the drawing
setHiddenLayers() called load(result, {keepView:true}), so every layer
on/off re-parsed and re-tessellated the whole drawing — arc sampling,
hatch triangulation, complex linetypes and the Slug text batch all rebuilt
from scratch. On an 18 MB DWG a single eye-icon click took 5 s+.

Build every layer once, then switch geometry on and off:

- Merged LineSegments buffers get an index buffer plus a per-segment layer
  id (_registerIndexed). A toggle rewrites the index and setDrawRange;
  positions, colors and lineDistances are untouched, so draw order, dash
  phase and the selection-highlight offsets in meta.colStart stay valid.
- Per-entity meshes (hatch fills, SOLID quads, arrowheads, OLE images,
  sprites) are tagged by layer via _addObj and toggled with .visible.
- Slug text builds one merged mesh per CAD layer instead of one per
  draw order, so text hides with a visible flag. The shader source is
  identical across batches, so three still shares one WebGLProgram.
- Hidden geometry stays in the pick buffers, so _onClick now skips it via
  the _metaHidden mask.
- Fit keeps framing only what is drawn: per-layer Box3 + fit samples are
  recorded at build and unioned over the visible layers.
- getLayerInfo() caches the O(entities) name/color/count pass per load;
  only the visible flag is recomputed.

Layer panel: one delegated click listener instead of re-binding a handler
per row, and a toggle patches the affected row's opacity/icon in place
rather than regenerating the whole list's innerHTML.

Trade-off: hidden layers are now built and kept in memory, so loading a
file with layers already off costs what a full load costs.

Rebuilds dist2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:08:59 +09:00

196 lines
6.4 KiB
TypeScript

/**
* Layer Panel manager for dwg-dxf-viewer-sample.
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
*/
import type { Viewer2D } from './viewer2d/Viewer2D';
export interface LayerItem {
name: string;
colorHex: string;
count: number;
visible: boolean;
}
export class LayerPanelManager {
private viewer: Viewer2D;
private listEl: HTMLElement;
private countEl: HTMLElement | null;
private searchInputEl: HTMLInputElement | null;
private hiddenLayers: Set<string> = new Set();
private searchQuery: string = '';
constructor(
viewer: Viewer2D,
listEl: HTMLElement,
options?: {
countEl?: HTMLElement | null;
searchInputEl?: HTMLInputElement | null;
showAllBtnEl?: HTMLElement | null;
hideAllBtnEl?: HTMLElement | null;
}
) {
this.viewer = viewer;
this.listEl = listEl;
this.countEl = options?.countEl || null;
this.searchInputEl = options?.searchInputEl || null;
if (this.searchInputEl) {
this.searchInputEl.addEventListener('input', (e) => {
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
this.render();
});
}
if (options?.showAllBtnEl) {
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
}
if (options?.hideAllBtnEl) {
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
}
// Delegated click handling: bound once, so render() never has to re-attach
// a listener per row (that was O(layers) DOM work on every single toggle).
this.listEl.addEventListener('click', (e) => {
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
if (!row) return;
const layerName = row.dataset.name;
if (layerName) this.toggleLayer(layerName);
});
}
/** Refresh and re-render layer list from Viewer2D state */
public update(): void {
this.hiddenLayers.clear();
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
if (!l.visible) {
this.hiddenLayers.add(l.name);
}
}
this.render();
}
public showAllLayers(): void {
this.hiddenLayers.clear();
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRows();
}
public hideAllLayers(): void {
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
this.hiddenLayers.add(l.name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRows();
}
public toggleLayer(name: string): void {
if (this.hiddenLayers.has(name)) {
this.hiddenLayers.delete(name);
} else {
this.hiddenLayers.add(name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRow(name);
}
/** Patch one row in place — a full render() would rebuild the whole list. */
private syncRow(name: string): void {
const row = this.listEl?.querySelector<HTMLElement>(
`.layer-row[data-name="${cssEscape(name)}"]`
);
if (!row) return;
this.paintRow(row, !this.hiddenLayers.has(name));
}
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
private syncRows(): void {
if (!this.listEl) return;
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
const name = row.dataset.name;
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
});
}
private paintRow(row: HTMLElement, visible: boolean): void {
row.style.opacity = visible ? '1' : '0.4';
const btn = row.querySelector<HTMLElement>('.eye-btn');
if (btn) {
btn.textContent = visible ? '👁️' : '🕶️';
btn.title = visible ? '숨기기' : '표시하기';
}
}
public render(): void {
if (!this.listEl) return;
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
if (this.countEl) {
this.countEl.textContent = `${layers.length}개`;
}
if (!layers.length) {
this.listEl.innerHTML = `
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
도면에 레이어 정보가 없습니다
</div>`;
return;
}
const filtered = layers.filter((l) =>
l.name.toLowerCase().includes(this.searchQuery)
);
if (!filtered.length) {
this.listEl.innerHTML = `
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
</div>`;
return;
}
this.listEl.innerHTML = filtered
.map((l) => {
const isVisible = !this.hiddenLayers.has(l.name);
const opacity = isVisible ? '1' : '0.4';
const eyeIcon = isVisible ? '👁️' : '🕶️';
const eyeTitle = isVisible ? '숨기기' : '표시하기';
return `
<div class="layer-row" data-name="${escapeHtml(l.name)}"
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
${escapeHtml(l.name)}
</span>
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
${l.count}
</span>
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
${eyeIcon}
</button>
</div>`;
})
.join('');
// Clicks are handled by the delegated listener bound in the constructor.
}
}
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
function cssEscape(str: string): string {
return str.replace(/["\\]/g, '\\$&');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}