Deploy Storybook / deploy (push) Successful in 2m10s
- Property popup: row labels 14px 500, read-only values 14px in the panel text color, monospace removed; 일반 정보 / 기하 속성 groups 20px apart (--hm-sw-viewer-panel-spacing on the second section). - Layer popup: 전체 레이어 is a plain SWViewerSection title (existing .hm-sw-viewer-section__title) with the all-layers switch on the same row; layer names 14px 500, no monospace. - Options popup follows the user image: 표시 (LWDISPLAY), 표시 배율 with the px/mm unit on the input row, updated lineweight hints. - Remove stray monospace font-family from SW Story CSS. - VIEWER.md §4.1 / new §4.2, DESIGN_RULES §11, Example H aligned.
206 lines
14 KiB
TypeScript
206 lines
14 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, SWViewerSlider, 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"
|
|
|
|
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 switch for all layers. ON only when every layer is shown; a partial state reads OFF, so a click turns all on.
|
|
const allVisible = demo.hidden.size === 0
|
|
return (
|
|
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
|
|
<SWViewerSection surface="plain" aria-label="레이어 목록">
|
|
<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 switch row; the switch stays on that line. */}
|
|
<SWViewerSection surface="plain" title="전체 레이어" className="sw-viewer-story-all">
|
|
<SWViewerSwitch aria-label="전체 레이어" checked={allVisible}
|
|
onCheckedChange={next => { set(next ? [] : LAYERS.map(layer => layer.name)); demo.record(`layer:all=${next}`) }} />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
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={<SWViewerSlider 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, footer, ...placement }: { demo: ViewerDemo; footer?: boolean } & 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>}
|
|
footer={footer ? <><SWViewerButton onClick={() => demo.toggle("options", false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record("options:apply"); demo.toggle("options", false) }}>적용</SWViewerButton></> : undefined}>
|
|
<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>
|
|
)
|
|
}
|