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>
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|