Files
HMDesign/packages/center-graphic/src/components/TunnelFacility.tsx
T
shkim4 6b60096752
Deploy Storybook / deploy (push) Successful in 1m11s
feat(center-graphic): tunnel and bridge section shapes in simple examples
- CompactFacilityConnection gets an optional segmentShape ('tunnel' |
  'bridge'); omitted keeps the narrow centre strip
- Tunnel: 30% rounded section with a white outline and a dot on each end
- Bridge: 30% square section with white end lines and a square mark at
  each end
2026-10-26 20:15:10 +09:00

213 lines
9.2 KiB
TypeScript

import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import positionMapLeft from '../assets/facility/tunnel/position-map/left.svg';
import positionMapCenter from '../assets/facility/tunnel/position-map/center.svg';
import positionMapRight from '../assets/facility/tunnel/position-map/right.svg';
import actualDetailLeft from '../assets/facility/tunnel/actual-detail/left.svg';
import actualDetailCenter from '../assets/facility/tunnel/actual-detail/center.svg';
import actualDetailRight from '../assets/facility/tunnel/actual-detail/right.svg';
import './TunnelFacility.css';
import { CompactFacilityConnection } from './CompactFacilityConnection';
export const TUNNEL_ASSET_MAP = Object.freeze({
positionMap: Object.freeze({
semanticId: 'facility.tunnel.position-map',
sourceDirectory: '시설물 표현/터널/위치도',
left: Object.freeze({ src: positionMapLeft, width: 43, height: 93, role: 'fixed-left-terminal' }),
center: Object.freeze({ src: positionMapCenter, width: 99, height: 93, role: 'horizontal-flex-center' }),
right: Object.freeze({ src: positionMapRight, width: 43, height: 93, role: 'fixed-right-terminal' }),
separatedGap: Object.freeze({ leftToCenter: 7, centerToRight: 5 }),
}),
actualDetail: Object.freeze({
semanticId: 'facility.tunnel.actual-detail',
sourceDirectory: '시설물 표현/터널/실사화',
left: Object.freeze({ src: actualDetailLeft, width: 48, height: 95, role: 'fixed-left-terminal' }),
center: Object.freeze({ src: actualDetailCenter, width: 84, height: 95, role: 'horizontal-flex-center' }),
right: Object.freeze({ src: actualDetailRight, width: 48, height: 95, role: 'fixed-right-terminal' }),
separatedGap: Object.freeze({ leftToCenter: 10, centerToRight: 9 }),
}),
});
export type TunnelVariant = keyof typeof TUNNEL_ASSET_MAP;
const VARIANT_LABEL: Record<TunnelVariant, string> = {
positionMap: '위치도',
actualDetail: '실사화',
};
const SOURCE_REFERENCE_NAME = '대산2터널';
const SOURCE_REFERENCE_TOTALS = Object.freeze({
positionMap: 210,
actualDetail: 220,
});
function classNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
}
export function TunnelFacility({
name = '대산2터널',
secondaryText = 'L=750m',
variant = 'positionMap',
separated = false,
showLabels = !separated,
className = '',
}: {
name?: string;
secondaryText?: string;
variant?: TunnelVariant;
separated?: boolean;
showLabels?: boolean;
className?: string;
}) {
const asset = TUNNEL_ASSET_MAP[variant];
if (!asset) throw new Error(`Unsupported Tunnel visual variant: ${variant}`);
const primaryProbeRef = useRef<HTMLSpanElement>(null);
const secondaryProbeRef = useRef<HTMLSpanElement>(null);
const [measuredCenterWidth, setMeasuredCenterWidth] = useState<number>(asset.center.width);
useLayoutEffect(() => {
if (separated) {
setMeasuredCenterWidth(asset.center.width);
return undefined;
}
let active = true;
const measure = () => {
if (!active) return;
const primaryWidth = primaryProbeRef.current ? Number.parseFloat(getComputedStyle(primaryProbeRef.current).width) : 0;
const secondaryWidth = secondaryProbeRef.current ? Number.parseFloat(getComputedStyle(secondaryProbeRef.current).width) : 0;
const referenceCenterWidth = SOURCE_REFERENCE_TOTALS[variant] - asset.left.width - asset.right.width;
const nextWidth = name === SOURCE_REFERENCE_NAME
? Math.max(asset.center.width, referenceCenterWidth)
: Math.max(asset.center.width, Math.ceil(primaryWidth), Math.ceil(secondaryWidth));
setMeasuredCenterWidth((current) => current === nextWidth ? current : nextWidth);
};
measure();
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
if (observer && primaryProbeRef.current) observer.observe(primaryProbeRef.current);
if (observer && secondaryProbeRef.current) observer.observe(secondaryProbeRef.current);
document.fonts?.ready.then(measure);
return () => {
active = false;
observer?.disconnect();
};
}, [asset, name, secondaryText, separated]);
const centerWidth = separated ? asset.center.width : measuredCenterWidth;
const leftGap = separated ? asset.separatedGap.leftToCenter : 0;
const rightGap = separated ? asset.separatedGap.centerToRight : 0;
const accessibleName = [name, secondaryText, VARIANT_LABEL[variant], separated ? '분리 표현' : null]
.filter(Boolean)
.join(', ');
const style = {
'--tunnel-height': `${asset.center.height}px`,
'--tunnel-left-width': `${asset.left.width}px`,
'--tunnel-center-base-width': `${asset.center.width}px`,
'--tunnel-center-width': `${centerWidth}px`,
'--tunnel-center-scale': centerWidth / asset.center.width,
'--tunnel-right-width': `${asset.right.width}px`,
'--tunnel-left-gap': `${leftGap}px`,
'--tunnel-right-gap': `${rightGap}px`,
} as CSSProperties;
return (
<figure
className={classNames('tunnel-facility', `tunnel-facility--${variant}`, separated && 'is-separated', className)}
style={style}
role="img"
aria-label={accessibleName}
data-tunnel-variant={variant}
data-separated={separated ? 'true' : 'false'}
data-left-width={asset.left.width}
data-center-width={centerWidth}
data-right-width={asset.right.width}
data-height={asset.center.height}
data-source-reference-total={name === SOURCE_REFERENCE_NAME ? SOURCE_REFERENCE_TOTALS[variant] : undefined}
>
<span className="tunnel-facility__segment tunnel-facility__segment--left" aria-hidden="true">
<img src={asset.left.src} alt="" draggable="false" />
</span>
<span className="tunnel-facility__segment tunnel-facility__segment--center" aria-hidden="true">
<img src={asset.center.src} alt="" draggable="false" />
</span>
<span className="tunnel-facility__segment tunnel-facility__segment--right" aria-hidden="true">
<img src={asset.right.src} alt="" draggable="false" />
</span>
{showLabels && (
<span className="tunnel-facility__labels" aria-hidden="true">
<span className="tunnel-facility__primary">{name}</span>
{secondaryText ? <span className="tunnel-facility__secondary">{secondaryText}</span> : null}
</span>
)}
<span className="tunnel-facility__measure" aria-hidden="true">
<span ref={primaryProbeRef} className="tunnel-facility__primary">{name}</span>
<span ref={secondaryProbeRef} className="tunnel-facility__secondary">{secondaryText}</span>
</span>
</figure>
);
}
function TunnelSample({ title, note, children }: { title: string; note?: string; children?: ReactNode }) {
return (
<article className="tunnel-reference__sample">
<header>
<strong>{title}</strong>
{note ? <span>{note}</span> : null}
</header>
<div className="tunnel-reference__stage">{children}</div>
</article>
);
}
function CompactTunnelExample() {
return (
<CompactFacilityConnection className="tunnel-applied-example" routeMode="inline" segmentShape="tunnel" sourceStatus="SOURCE_RECEIVED" label="노선과 터널 시설물의 간략 적용 예제">
<TunnelFacility name="대산2터널" secondaryText="L=750m" variant="positionMap" />
</CompactFacilityConnection>
);
}
export function TunnelReferenceDemo() {
return (
<div className="tunnel-reference">
<div className="tunnel-reference__variant-grid">
<TunnelSample title="Position Map" note="로컬 SVG · 위치도">
<TunnelFacility variant="positionMap" />
</TunnelSample>
<TunnelSample title="Actual / Detail" note="로컬 SVG · 실사화">
<TunnelFacility variant="actualDetail" />
</TunnelSample>
</div>
<TunnelSample title="Separated" note="같은 개체 · Source 작성 부분 간격">
<div className="tunnel-reference__row">
<TunnelFacility variant="positionMap" separated />
<TunnelFacility variant="actualDetail" separated />
</div>
</TunnelSample>
<TunnelSample title="너비 동작" note="CENTER만 수평 확장">
<div className="tunnel-reference__width-tests">
<div><span>SHORT</span><TunnelFacility name="터널" secondaryText="L=80m" variant="positionMap" /></div>
<div><span>MEDIUM</span><TunnelFacility name="대산2터널" secondaryText="L=750m" variant="positionMap" /></div>
<div><span>LONG</span><TunnelFacility name="서해안고속도로 대산연결 제2터널" secondaryText="L=12,480m" variant="positionMap" /></div>
<div><span>ACTUAL SHORT</span><TunnelFacility name="터널" secondaryText="L=80m" variant="actualDetail" /></div>
<div><span>ACTUAL REF</span><TunnelFacility name="대산2터널" secondaryText="L=750m" variant="actualDetail" /></div>
<div><span>ACTUAL LONG</span><TunnelFacility name="서해안고속도로 대산연결 제2터널" secondaryText="L=12,480m" variant="actualDetail" /></div>
</div>
</TunnelSample>
<TunnelSample title="간략 적용 예제" note="노선 + 연결 + 터널 + 레이블">
<CompactTunnelExample />
</TunnelSample>
</div>
);
}