Files
dwg-dxf-viewer-hm/src/App.tsx
T
minsungandClaude Opus 5.5 855b7f4ad6 perf(ui): update the download progress once per visible change
fetchWithProgress reports every network chunk; only re-render when the
"x MB / y MB" label actually changes (#1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:59:10 +09:00

440 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"
import { flushSync } from "react-dom"
import { Input, Toaster, TooltipProvider, toast } from "@hm-design/ui"
import type { Viewer2D } from "./engine/viewer2d/Viewer2D.js"
import { listCadSpaces, pickDefaultSpace, type CadSpace } from "./engine/viewer2d/cadSpaces"
import { is2D } from "./engine/viewer2d/ext2d"
import { LayerPanelManager, type LayerItem } from "./engine/layerPanel"
import { getAllOptions, setOption } from "./engine/optionsStore"
import { nameFromUrl, parseCad, parserLabel } from "./lib/cad"
import { fetchWithProgress, formatBytes, nextPaint } from "./lib/loadProgress"
import type { SampleDrawing } from "./lib/samples"
import { createValueStore } from "./lib/store"
import { DwgViewerCanvas, type CadPoint, type ViewerDisplayOptions } from "./components/DwgViewerCanvas"
import { AppHeader } from "./components/AppHeader"
import { ViewerToolbar } from "./components/ViewerToolbar"
import { StatusBar, type StatusMessage } from "./components/StatusBar"
import { LayersPanel } from "./components/LayersPanel"
import { PropertiesPanel } from "./components/PropertiesPanel"
import { OptionsPanel, type EngineOptionState } from "./components/OptionsPanel"
import { AboutDialog } from "./components/AboutDialog"
import { DropOverlay, EmptyState, LoadingOverlay, type LoadState } from "./components/StageOverlays"
/** Persisted options (IndexedDB hmw-viewer/options). `undefined` = never set → engine default. */
type Prefs = {
lineweightScale?: number | null
lineweightOn?: boolean | null
grid: boolean
zoomSpeed?: number
zScale?: number
}
type Source = { kind: "url"; url: string; name?: string } | { kind: "file"; file: File }
const positive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
export function App() {
const [viewer, setViewer] = useState<Viewer2D | null>(null)
const viewerRef = useRef<Viewer2D | null>(null)
const layerManager = useRef<LayerPanelManager | null>(null)
const stageRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const pointer = useMemo(() => createValueStore<CadPoint | null>(null), [])
const zoom = useMemo(() => createValueStore<number | null>(null), [])
const [status, setStatus] = useState<StatusMessage>({ text: "준비 중…" })
const [loadState, setLoadState] = useState<LoadState | null>(null)
const [hasDrawing, setHasDrawing] = useState(false)
const [spaces, setSpaces] = useState<CadSpace[]>([])
const [activeSpace, setActiveSpace] = useState<string | null>(null)
const [layers, setLayers] = useState<LayerItem[]>([])
const [selected, setSelected] = useState<unknown>(null)
const [panels, setPanels] = useState({ layers: false, props: false, options: false })
const [aboutOpen, setAboutOpen] = useState(false)
const aboutOpener = useRef<HTMLElement | null>(null)
const [dragging, setDragging] = useState(false)
const [dark, setDark] = useState(true)
const [view25D, setView25D] = useState(false)
const [prefs, setPrefs] = useState<Prefs>({ grid: false })
const [engine, setEngine] = useState<EngineOptionState>({
lineweightEnabled: false,
lineweightScale: 20,
lineweightClasses: [],
zoomSpeed: 1,
zScale: 1,
})
const displayOptions = useMemo<ViewerDisplayOptions>(
() => ({
dark,
grid: prefs.grid,
zoomSpeed: prefs.zoomSpeed,
lineweightEnabled: prefs.lineweightOn,
lineweightScale: prefs.lineweightScale,
zScale: prefs.zScale,
view25D,
}),
[dark, prefs, view25D],
)
// ── engine → React (reads only) ─────────────────────────────────────────────
/** main.ts syncOptions(): put the controls back to what the engine actually uses. */
const readEngine = useCallback(() => {
const v = viewerRef.current
if (!v) return
setEngine({
lineweightEnabled: v.getLineweightEnabled(),
lineweightScale: v.getLineweightScale(),
lineweightClasses: v.getLineweightClasses(),
zoomSpeed: v.getZoomSpeed(),
zScale: v.getZScale(),
})
zoom.set(v.getZoomPercent())
}, [zoom])
const fillLayers = useCallback(() => {
const v = viewerRef.current
if (!v) return
layerManager.current?.update()
setLayers(v.getLayerInfo())
}, [])
const fillSpaces = useCallback(() => {
const v = viewerRef.current
if (!v) return
setSpaces(v.getSpaces())
setActiveSpace(v.getActiveSpace())
}, [])
const onReady = useCallback((v: Viewer2D | null) => {
viewerRef.current = v
// The ported manager owns the hidden-layer set and calls Viewer2D.setHiddenLayers(); its own DOM rendering
// goes to a detached element because the list is rendered by React (LayersPanel) from getLayerInfo().
layerManager.current = v ? new LayerPanelManager(v, document.createElement("div")) : null
setViewer(v)
}, [])
const onSelect = useCallback((entity: unknown) => {
if (!entity) {
setStatus({ text: "선택 해제" })
setSelected(null)
return
}
// Show the properties window automatically on selection.
setPanels((p) => ({ ...p, props: true }))
setSelected(entity)
const e = entity as { type?: string; layer?: string; handle?: { value?: string } }
setStatus({ text: `선택 ${e.type ?? "?"} · 레이어 ${e.layer ?? "-"} · 핸들 ${e.handle?.value ?? "-"}` })
}, [])
const onViewChange = useCallback(() => {
zoom.set(viewerRef.current?.getZoomPercent() ?? null)
}, [zoom])
// Theme changes re-render the drawing with theme-dependent layer colors (the sample re-filled the list too).
// Child effects run first, so the canvas has already called setTheme() here.
useEffect(() => {
if (viewer) fillLayers()
}, [viewer, dark, fillLayers])
// ── load pipeline (main.ts ④/⑤ with staged progress) ─────────────────────────
const loadSeq = useRef(0)
const run = useCallback(
async (label: string, source: Source) => {
const v = viewerRef.current
if (!v) return
const seq = ++loadSeq.current
const stale = () => seq !== loadSeq.current
setStatus({ text: `${label} 여는 중…` })
setLoadState({ label: "도면 여는 중", value: 0, busy: true })
try {
let buf: ArrayBuffer
let name: string
if (source.kind === "url") {
name = source.name ?? nameFromUrl(source.url)
let lastLabel = ""
buf = await fetchWithProgress(source.url, (loaded, total) => {
if (stale()) return
const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded)
const label = `도면 내려받는 중 (${size})`
if (label === lastLabel) return // one update per visible change, not per network chunk
lastLabel = label
setLoadState({ label, value: total ? Math.round(Math.min(1, loaded / total) * 50) : null, busy: true })
})
} else {
name = source.file.name
if (!is2D(name)) throw new Error(`.dwg / .dxf 파일이 아닙니다: ${name}`)
setLoadState({ label: "파일 읽는 중", value: 10, busy: true })
buf = await source.file.arrayBuffer()
}
if (stale()) return
// Parsing blocks the main thread (WASM / dxf-parser): commit and paint the busy state first.
flushSync(() => setLoadState({ label: `도면 해석 중 · ${parserLabel(buf, name)}`, value: 55, busy: true }))
await nextPaint()
const result = await parseCad(buf, name)
if (stale()) return
// Viewer2D.load() tessellates synchronously as well.
flushSync(() => setLoadState({ label: "화면 구성 중", value: 85, busy: true }))
await nextPaint()
const allSpaces = listCadSpaces(result)
const def = pickDefaultSpace(allSpaces)
v.load(result, { spaceHandle: def?.handleHex ?? null })
v.resize()
readEngine()
const active = allSpaces.find((s) => s.handleHex === v.getActiveSpace())
setStatus({ text: `✓ ${label} · 엔티티 ${result.entities?.length ?? 0}개${active ? ` · ${active.label}` : ""}` })
setHasDrawing(true)
setSelected(null)
fillSpaces()
fillLayers()
v.fit()
zoom.set(v.getZoomPercent())
setLoadState({ label: "완료", value: 100, busy: false })
window.setTimeout(() => {
if (!stale()) setLoadState(null)
}, 400)
} catch (err) {
if (stale()) return
const message = (err as Error).message
setStatus({ text: `${label}: ${message}`, error: true })
setLoadState(null)
toast.error(`${label} 열기 실패`, { description: message })
console.error(err)
}
},
[readEngine, fillSpaces, fillLayers, zoom],
)
const runRef = useRef(run)
useEffect(() => {
runRef.current = run
}, [run])
const openAbout = useCallback(() => {
aboutOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setAboutOpen(true)
}, [])
const loadSample = useCallback((s: SampleDrawing) => void run(s.name, { kind: "url", url: s.path, name: s.name }), [run])
const openFile = useCallback(() => fileRef.current?.click(), [])
const onFileChange = (ev: ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0]
if (!file) return
void run(file.name, { kind: "file", file })
ev.target.value = ""
}
// `?model=` auto-load, once the engine exists.
const booted = useRef(false)
useEffect(() => {
if (!viewer || booted.current) return
booted.current = true
const model = new URLSearchParams(location.search).get("model")
if (model) void runRef.current(model, { kind: "url", url: model })
else setStatus({ text: "준비됨 · 도면을 열거나 화면에 끌어다 놓으세요" })
}, [viewer])
// Restore saved options. IndexedDB is async; values are applied as soon as they arrive.
useEffect(() => {
let alive = true
void getAllOptions().then((o) => {
if (!alive) return
setPrefs((p) => ({
...p,
...(positive(o.lineweightScale) ? { lineweightScale: o.lineweightScale } : {}),
...(typeof o.lineweightOn === "boolean" ? { lineweightOn: o.lineweightOn } : {}),
...(o.grid === true ? { grid: true } : {}),
...(positive(o.zoomSpeed) ? { zoomSpeed: o.zoomSpeed } : {}),
...(positive(o.zScale) ? { zScale: o.zScale } : {}),
}))
})
return () => {
alive = false
}
}, [])
// F = fit (the sample's host shortcut; skipped while typing in an input).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.key === "f" || e.key === "F") && (e.target as HTMLElement)?.tagName !== "INPUT") viewerRef.current?.fit()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
// Drag & drop anywhere on the page.
useEffect(() => {
let depth = 0
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files")
const onOver = (e: DragEvent) => e.preventDefault()
const onEnter = (e: DragEvent) => {
if (!hasFiles(e)) return
depth++
setDragging(true)
}
const onLeave = (e: DragEvent) => {
if (!hasFiles(e)) return
depth = Math.max(0, depth - 1)
if (!depth) setDragging(false)
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
depth = 0
setDragging(false)
const file = e.dataTransfer?.files?.[0]
if (file) void runRef.current(file.name, { kind: "file", file })
}
window.addEventListener("dragover", onOver)
window.addEventListener("dragenter", onEnter)
window.addEventListener("dragleave", onLeave)
window.addEventListener("drop", onDrop)
return () => {
window.removeEventListener("dragover", onOver)
window.removeEventListener("dragenter", onEnter)
window.removeEventListener("dragleave", onLeave)
window.removeEventListener("drop", onDrop)
}
}, [])
// ── commands (React → engine) ───────────────────────────────────────────────
const onSpace = (s: CadSpace) => {
const v = viewerRef.current
if (!v || s.handleHex === v.getActiveSpace()) return
v.setSpace(s.handleHex)
fillSpaces()
fillLayers()
v.fit()
zoom.set(v.getZoomPercent())
setStatus({ text: `공간 ${s.label} · 엔티티 ${s.entityCount}개` })
}
const apply25D = (on: boolean) => {
setView25D(on)
setStatus({ text: on ? "2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동" : "Top 뷰" })
}
const onToggleLayer = (name: string) => {
layerManager.current?.toggleLayer(name)
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
const onShowAll = () => {
layerManager.current?.showAllLayers()
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
const onHideAll = () => {
layerManager.current?.hideAllLayers()
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
// Options: state → canvas effect → engine; each change is saved to IndexedDB like the sample.
const onLineweightScale = (v: number) => {
setPrefs((p) => ({ ...p, lineweightScale: v }))
void setOption("lineweightScale", v)
}
const onLineweightEnabled = (on: boolean) => {
setPrefs((p) => ({ ...p, lineweightOn: on }))
void setOption("lineweightOn", on)
}
const onGrid = (on: boolean) => {
setPrefs((p) => ({ ...p, grid: on }))
void setOption("grid", on)
}
const onZoomSpeed = (v: number) => {
setPrefs((p) => ({ ...p, zoomSpeed: v }))
void setOption("zoomSpeed", v)
}
const onZScale = (v: number) => {
setPrefs((p) => ({ ...p, zScale: v }))
void setOption("zScale", v)
}
const onReset = () => {
// Engine default lineweight scale, the drawing's own LWDISPLAY, grid off, zoom ×1, Z ×1, top view.
setPrefs({ lineweightScale: null, lineweightOn: null, grid: false, zoomSpeed: 1, zScale: 1 })
apply25D(false)
void setOption("lineweightScale", null)
void setOption("lineweightOn", null)
void setOption("grid", false)
void setOption("zoomSpeed", 1)
void setOption("zScale", 1)
}
return (
<TooltipProvider>
<div className="viewer-app">
<AppHeader onOpenFile={openFile} onSample={loadSample} onAbout={openAbout} />
<main className="viewer-main" aria-label="도면 뷰어">
<ViewerToolbar
spaces={spaces}
activeSpace={activeSpace}
onSpace={onSpace}
hasDrawing={hasDrawing}
onFit={() => viewerRef.current?.fit()}
view25D={view25D}
on25D={apply25D}
dark={dark}
onDark={setDark}
layersOpen={panels.layers}
onLayers={(open) => setPanels((p) => ({ ...p, layers: open }))}
propsOpen={panels.props}
onProps={(open) => setPanels((p) => ({ ...p, props: open }))}
optionsOpen={panels.options}
onOptions={(open) => setPanels((p) => ({ ...p, options: open }))}
/>
<div ref={stageRef} className="viewer-stage">
<DwgViewerCanvas
className="viewer-canvas"
options={displayOptions}
onReady={onReady}
onSelect={onSelect}
onPointerWorld={pointer.set}
onViewChange={onViewChange}
onOptionsApplied={readEngine}
/>
{!hasDrawing && !loadState ? <EmptyState onOpenFile={openFile} onSample={loadSample} /> : null}
{loadState ? <LoadingOverlay state={loadState} /> : null}
{dragging ? <DropOverlay /> : null}
<LayersPanel
open={panels.layers}
onClose={() => setPanels((p) => ({ ...p, layers: false }))}
anchorRef={stageRef}
layers={layers}
onToggle={onToggleLayer}
onShowAll={onShowAll}
onHideAll={onHideAll}
/>
<PropertiesPanel
open={panels.props}
onClose={() => setPanels((p) => ({ ...p, props: false }))}
anchorRef={stageRef}
entity={selected}
onZoomToSelection={() => viewerRef.current?.zoomToSelection()}
/>
<OptionsPanel
open={panels.options}
onClose={() => setPanels((p) => ({ ...p, options: false }))}
anchorRef={stageRef}
engine={engine}
grid={prefs.grid}
view25D={view25D}
onLineweightEnabled={onLineweightEnabled}
onLineweightScale={onLineweightScale}
onGrid={onGrid}
onZoomSpeed={onZoomSpeed}
on25D={apply25D}
onZScale={onZScale}
onReset={onReset}
/>
</div>
<StatusBar status={status} pointer={pointer} zoom={zoom} onNotices={openAbout} />
</main>
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
<Input ref={fileRef} type="file" accept=".dwg,.dxf" hidden aria-label="DWG / DXF 파일 선택" onChange={onFileChange} />
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} returnFocusRef={aboutOpener} />
<Toaster />
</div>
</TooltipProvider>
)
}