Deploy Storybook / deploy (push) Successful in 1m11s
- 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
213 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|