216 lines
15 KiB
TypeScript
216 lines
15 KiB
TypeScript
// Story fixture for SW/Viewer: the reference DWG/DXF viewer menu rebuilt from the public @hm-design/sw API only.
|
||
// UI state lives here (like a consuming program); no DWG/DXF engine — commands are logged, not executed.
|
||
import { useMemo, useState } from "react"
|
||
import {
|
||
SWLayerVisibilityIcon, SWViewerBadge, SWViewerButton, SWViewerHeaderButton, SWViewerStatus, SWViewerField, SWViewerHint, SWViewerInput, SWViewerPanel, SWViewerSection, SWSlider, SWViewerSwitch,
|
||
type SWHeaderDocument, type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerHeaderProps, type SWViewerPanelProps,
|
||
} from "@hm-design/sw"
|
||
import { Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
|
||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||
|
||
// 2026-10-23: the regular viewer Stories show the TEMPORARY placeholder logo of the SW Header Stories (not a product
|
||
// logo); only SW/Viewer › Logo › 로고 없음 renders without one.
|
||
export const VIEWER_STORY_LOGO = { logoSrc: placeholderLogo, logoAlt: "임시 로고" } satisfies Partial<SWViewerHeaderProps>
|
||
|
||
export const SAMPLES = [
|
||
{ id: "sample-basic", label: "기본 도면 · DWG", name: "BasicSample.dwg" },
|
||
{ id: "sample-civil", label: "평면 및 종단면도 · DWG", name: "C0060202-001-평면및종단면도.dwg" },
|
||
{ id: "sample-dxf", label: "기본 도형 · DXF", name: "simple.dxf" },
|
||
] as const
|
||
|
||
export const SPACES = [
|
||
{ value: "model", label: "Model", count: 17377 },
|
||
{ value: "layout1", label: "Layout", count: 237 },
|
||
]
|
||
|
||
export const LAYERS = [
|
||
{ name: "0", color: "#ffffff", count: 1 },
|
||
{ name: "CX-BORD-LIN1", color: "#00ffff", count: 30 },
|
||
{ name: "CX-BORD-LIN2", color: "#00ff00", count: 1 },
|
||
{ name: "CX-BORD-TEX3", color: "#ff00ff", count: 17 },
|
||
{ name: "CX-BORD-TEX2", color: "#000000", count: 4 },
|
||
{ name: "CX-MISC", color: "#ffff00", count: 0 },
|
||
{ name: "CR-LEAT", color: "#00ff00", count: 4 },
|
||
{ name: "CR-GRND", color: "#00ff00", count: 6 },
|
||
{ name: "CR-TEXT", color: "#ff0000", count: 52 },
|
||
]
|
||
|
||
export type ViewerPanelId = "layers" | "options" | "properties"
|
||
|
||
export interface ViewerOptions { lineweightOn: boolean; lineweightScale: number; grid: boolean; zoomSpeed: number; zScale: number }
|
||
export const DEFAULT_OPTIONS: ViewerOptions = { lineweightOn: false, lineweightScale: 11, grid: false, zoomSpeed: 1, zScale: 10 }
|
||
|
||
/** All UI state of the demo viewer screen + the header menu data built from it. */
|
||
export function useViewerDemo({ initialDocument = null as string | null, initialPanels = [] as ViewerPanelId[] } = {}) {
|
||
const [documentName, setDocumentName] = useState<string | null>(initialDocument)
|
||
const [space, setSpace] = useState("model")
|
||
const [is25D, set25D] = useState(false)
|
||
const [dark, setDark] = useState(true)
|
||
const [panels, setPanels] = useState<Record<ViewerPanelId, boolean>>(() => ({ layers: initialPanels.includes("layers"), options: initialPanels.includes("options"), properties: initialPanels.includes("properties") }))
|
||
const [options, setOptions] = useState<ViewerOptions>(DEFAULT_OPTIONS)
|
||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||
const [aboutOpen, setAboutOpen] = useState(false)
|
||
const [log, setLog] = useState<string[]>([])
|
||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||
const toggle = (id: ViewerPanelId, next = !panels[id]) => setPanels(current => ({ ...current, [id]: next }))
|
||
const loaded = documentName !== null
|
||
|
||
// 2026-10-21: file open is the header button after the logo (SWHeader documentOpenPlacement="header"); the samples
|
||
// are the file-name list (no Open / + New there).
|
||
const openFile = () => record("파일 선택창 (미연결)")
|
||
const documents = SAMPLES.map(sample => ({ id: sample.id, name: sample.name, title: sample.label }))
|
||
const fileProps = {
|
||
documents,
|
||
activeDocumentId: SAMPLES.find(sample => sample.name === documentName)?.id,
|
||
onDocumentSelect: (document: SWHeaderDocument) => { setDocumentName(document.name); record(`samples/${document.id}`) },
|
||
onDocumentOpen: openFile,
|
||
documentOpenLabel: "파일 열기",
|
||
} satisfies Partial<SWViewerHeaderProps>
|
||
|
||
const groups = useMemo<SWViewerHeaderGroup[]>(() => [
|
||
{
|
||
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
|
||
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
|
||
],
|
||
},
|
||
{
|
||
id: "view", label: "화면 보기", surface: "field", items: [
|
||
// 2026-10-21: shown while the header has room, icon only next, then into More (measured, see SWViewerHeader).
|
||
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, collapseBelow: 1180, disabled: !loaded },
|
||
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D },
|
||
{ type: "switch", id: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
|
||
],
|
||
},
|
||
{
|
||
id: "panels", label: "패널", placement: "end", items: [
|
||
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, iconOnlyBelow: 900, onPressedChange: next => toggle("layers", next) },
|
||
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, iconOnlyBelow: 900, onPressedChange: next => toggle("options", next) },
|
||
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, iconOnlyBelow: 900, onPressedChange: next => toggle("properties", next) },
|
||
],
|
||
},
|
||
], [loaded, space, is25D, dark, panels])
|
||
|
||
/** Trailing slot: load state + the info button that opens the modal about popup (reference LicenseNotice). */
|
||
const trailing = (
|
||
<>
|
||
<SWViewerStatus state={loaded ? "ready" : "idle"} detail={loaded ? `${documentName} 열림` : "deselected"} />
|
||
<SWViewerHeaderButton iconOnly icon={<Info />} label="정보 및 라이선스" title="정보 및 라이선스" onClick={() => { setAboutOpen(true); record("about") }} />
|
||
</>
|
||
)
|
||
|
||
const onCommand = (command: SWViewerCommand) => record(`${command.itemId}${command.id !== command.itemId ? `/${command.id}` : ""}${command.value !== undefined ? `=${String(command.value)}` : ""}`)
|
||
|
||
return {
|
||
documentName, setDocumentName, space, is25D, set25D, dark, setDark, panels, toggle, options, setOptions, hidden, setHidden,
|
||
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen, fileProps,
|
||
}
|
||
}
|
||
|
||
export type ViewerDemo = ReturnType<typeof useViewerDemo>
|
||
|
||
type PanelPlacement = Pick<SWViewerPanelProps, "strategy" | "defaultPosition" | "inset">
|
||
|
||
export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelPlacement) {
|
||
const [query, setQuery] = useState("")
|
||
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||
const set = (names: string[]) => demo.setHidden(new Set(names))
|
||
// 2026-10-22: one bulb for all layers (was a switch). ON only when every layer is shown; a partial state reads OFF, so a click turns all on.
|
||
const allVisible = demo.hidden.size === 0
|
||
const setAll = (next: boolean) => { set(next ? [] : LAYERS.map(layer => layer.name)); demo.record(`layer:all=${next}`) }
|
||
return (
|
||
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
|
||
{/* 2026-10-22: search + 전체 레이어 stay fixed; only the layer list scrolls (.sw-viewer-story-layers). */}
|
||
<SWViewerSection surface="plain" aria-label="레이어 목록" className="sw-viewer-story-layers">
|
||
<div className="sw-viewer-story-toolbar">
|
||
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
|
||
{/* 2026-10-22: the section title (.hm-sw-viewer-section__title) names the row; the bulb (same button as the rows)
|
||
stays on that line and keeps the former switch semantics (role="switch", name 전체 레이어). */}
|
||
<SWViewerSection surface="plain" title="전체 레이어" className="sw-viewer-story-all">
|
||
<button type="button" role="switch" aria-checked={allVisible} aria-label="전체 레이어" className="sw-viewer-story-bulb"
|
||
title={allVisible ? "전체 숨기기" : "전체 표시하기"} onClick={() => setAll(!allVisible)}><SWLayerVisibilityIcon on={allVisible} /></button>
|
||
</SWViewerSection>
|
||
</div>
|
||
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
|
||
<ul className="sw-viewer-story-list">
|
||
{filtered.map(layer => {
|
||
const visible = !demo.hidden.has(layer.name)
|
||
return (
|
||
<li key={layer.name} data-visible={visible}>
|
||
<span className="sw-viewer-story-swatch" style={{ background: layer.color }} aria-hidden="true" />
|
||
<span className="sw-viewer-story-name" title={layer.name}>{layer.name}</span>
|
||
<SWViewerBadge>{layer.count}</SWViewerBadge>
|
||
<button type="button" className="sw-viewer-story-bulb" aria-label={`${layer.name} ${visible ? "숨기기" : "표시하기"}`} title={visible ? "숨기기" : "표시하기"}
|
||
onClick={() => {
|
||
const next = new Set(demo.hidden)
|
||
if (visible) next.add(layer.name); else next.delete(layer.name)
|
||
demo.setHidden(next)
|
||
demo.record(`layer:${layer.name}=${!visible}`)
|
||
}}><SWLayerVisibilityIcon on={visible} /></button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</SWViewerSection>
|
||
</SWViewerPanel>
|
||
)
|
||
}
|
||
|
||
// 2026-10-22: the viewer gauges use SWSlider style B (appearance="property", as SW/Popup › Custom Content), not
|
||
// SWViewerSlider (fixed to the green panel look).
|
||
function SliderRow({ label, unit, value, min, max, step, disabled, onChange }: { label: string; unit?: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||
return (
|
||
<SWViewerField label={label} unit={unit} layout="stacked" disabled={disabled}
|
||
below={<SWSlider appearance="property" aria-label={label} value={value} min={min} max={max} step={step} onValueChange={onChange} />}>
|
||
{id => <SWViewerInput id={id} type="number" min={min} max={max} step={step} value={value} onChange={event => { const next = Number(event.currentTarget.value); if (Number.isFinite(next)) onChange(next) }} />}
|
||
</SWViewerField>
|
||
)
|
||
}
|
||
|
||
export function OptionsPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelPlacement) {
|
||
const { options, setOptions } = demo
|
||
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
|
||
return (
|
||
<SWViewerPanel title="옵션" open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
|
||
headerActions={<SWViewerButton variant="ghost" aria-label="기본값으로" title="기본값으로" onClick={() => { setOptions(DEFAULT_OPTIONS); demo.record("options:reset") }}><RotateCcw size={16} /></SWViewerButton>}>
|
||
{/* 2026-10-22: no 취소 / 적용 footer — every toggle, input and slider applies at once. */}
|
||
<SWViewerSection title="선가중치">
|
||
{/* 2026-10-22 options image: LWDISPLAY toggle; scale input + px/mm unit on the label row, slider below. */}
|
||
<SWViewerField label="표시 (LWDISPLAY)">{id => <SWViewerSwitch id={id} checked={options.lineweightOn} onCheckedChange={lineweightOn => set({ lineweightOn })} />}</SWViewerField>
|
||
<SliderRow label="표시 배율" unit="px/mm" value={options.lineweightScale} min={2} max={60} step={0.5} onChange={lineweightScale => set({ lineweightScale })} />
|
||
<SWViewerHint>이 도면에는 굵은 선이 없습니다 (0.50mm였다면 {(0.5 * options.lineweightScale).toFixed(1)}px)</SWViewerHint>
|
||
<SWViewerHint>AutoCAD "선가중치 설정 → 표시 배율"과 같습니다. 모델 공간의 선가중치는 줌과 무관한 화면 픽셀로 그려지므로 화면 DPI에 맞춰 조정합니다.</SWViewerHint>
|
||
</SWViewerSection>
|
||
<SWViewerSection title="표시">
|
||
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={options.grid} onCheckedChange={grid => set({ grid })} />}</SWViewerField>
|
||
<SliderRow label="줌 속도" value={options.zoomSpeed} min={0.2} max={3} step={0.1} onChange={zoomSpeed => set({ zoomSpeed })} />
|
||
</SWViewerSection>
|
||
<SWViewerSection title="2.5D">
|
||
<SWViewerField label="2.5D 보기">{id => <SWViewerSwitch id={id} checked={demo.is25D} onCheckedChange={demo.set25D} />}</SWViewerField>
|
||
<SliderRow label="Z 높이 배율" value={options.zScale} min={1} max={50} step={0.5} disabled={!demo.is25D} onChange={zScale => set({ zScale })} />
|
||
<SWViewerHint>좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.</SWViewerHint>
|
||
</SWViewerSection>
|
||
</SWViewerPanel>
|
||
)
|
||
}
|
||
|
||
const ENTITY_ROWS: [string, string][] = [["유형", "LINE"], ["레이어", "CX-BORD-LIN1"], ["색상", "ByLayer"], ["선종류", "Continuous"]]
|
||
const GEOMETRY_ROWS: [string, string][] = [["시작 X", "18432.512"], ["시작 Y", "2210.004"], ["끝 X", "18992.040"], ["끝 Y", "2210.004"], ["길이", "559.528"]]
|
||
|
||
export function PropertiesPanel({ demo, selected = true, ...placement }: { demo: ViewerDemo; selected?: boolean } & PanelPlacement) {
|
||
const rows = (items: [string, string][]) => (
|
||
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
|
||
)
|
||
return (
|
||
<SWViewerPanel title="속성" open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
|
||
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
|
||
{selected ? <>
|
||
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
|
||
<SWViewerSection surface="plain" title="기하 속성 (Geometry)" className="sw-viewer-story-props-next">{rows(GEOMETRY_ROWS)}</SWViewerSection>
|
||
</> : (
|
||
<div className="sw-viewer-story-empty"><MousePointerClick size={24} aria-hidden="true" />도면에서 객체(엔티티)를 클릭하면<br />상세 속성이 표시됩니다</div>
|
||
)}
|
||
</SWViewerPanel>
|
||
)
|
||
}
|