Files
HMDesign/src/sw/sw-viewer-fixture.tsx
T
shkim4 84d70f5cdf
Deploy Storybook / deploy (push) Successful in 2m10s
style(sw): viewer popup typography, 전체 레이어 title and options layout
- 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.
2026-10-22 16:56:22 +09:00

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>
)
}