Files
HMDesign/src/sw/sw-viewer-fixture.tsx
T
shkim4 205864b8a0
Deploy Storybook / deploy (push) Successful in 1m13s
fix(sw): align header logo styles and refine viewer UI
2026-10-23 09:55:13 +09:00

216 lines
15 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.
// 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>
)
}