Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

@@ -0,0 +1,599 @@
/*
* CENTER Information Display — Reference Implementation styles.
*
* Every length is expressed in design units (u) via calc(N * var(--cid-u)), where the
* numbers N are the normalised values measured from the Source SVGs and recorded in
* verification/center-information-display/SOURCE-ANALYSIS.md.
*
* Setting --cid-u is how a consumer chooses a rendering scale. The Source itself does
* not declare an absolute size for 1 u (Inventory FU-2).
*/
.cid-scope {
--cid-u: 1px;
--cid-font: var(--center-font-family, 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif);
}
/* ── Shared card ──────────────────────────────────────────────────────── */
.cid-card {
min-width: 0;
box-sizing: border-box;
width: var(--cid-card-width, fit-content);
padding: calc(2 * var(--cid-u));
border-radius: calc(8 * var(--cid-u));
background: #ffffff;
box-shadow:
calc(4 * var(--cid-u)) calc(4 * var(--cid-u)) calc(4 * var(--cid-u)) rgba(0, 0, 0, 0.25);
font-family: var(--cid-font);
font-feature-settings: normal;
text-rendering: geometricPrecision;
}
.cid-card__header {
display: flex;
align-items: center;
justify-content: center;
height: var(--cid-header-height, calc(40.654 * var(--cid-u)));
padding: 0 calc(10 * var(--cid-u));
overflow: hidden;
/* The band's top radius is the one card value that does NOT scale with the family:
both export scales carry the same raw 8.133 px, so it is 5.7921 u in the box family
and 5.5894 u in the table family. Both are Source-measured, neither is derived. */
border-radius: var(--cid-header-radius, calc(5.7921 * var(--cid-u)))
var(--cid-header-radius, calc(5.7921 * var(--cid-u))) 0 0;
background: linear-gradient(90deg, var(--cid-header-from), var(--cid-header-to));
}
.cid-card__header > span {
min-width: 0;
text-overflow: ellipsis;
color: #ffffff;
font-size: var(--cid-header-size, calc(27.4 * var(--cid-u)));
font-weight: 700;
line-height: 1;
white-space: nowrap;
overflow: hidden;
-webkit-text-stroke: calc(2 * var(--cid-u)) rgba(0, 0, 0, 0.5);
paint-order: stroke fill;
}
.cid-card__body {
box-sizing: border-box;
margin-top: calc(2 * var(--cid-u));
background: #ffffff;
}
/*
* The body frame is NOT a CSS border: Chrome snaps used border-width to a coarse
* grid (calc(4 * 1.4041px) resolves to 5.333px, not 5.6164px), which breaks
* sub-pixel parity with the Source. Padding + a layered inner surface is exact.
*/
.cid-card__body--list,
.cid-card__body--emphasis {
position: relative;
border-radius: 0 0 calc(6 * var(--cid-u)) calc(6 * var(--cid-u));
background: var(--cid-border);
}
/* The Source paints the inner content surface as a plain, UNROUNDED <rect> inside the
rounded frame — its square corners clear the 6 u curve, so no rounding is applied. */
.cid-card__body--list::before,
.cid-card__body--emphasis::before {
content: '';
position: absolute;
inset: calc(4 * var(--cid-u));
z-index: 0;
border-radius: 0;
background: #ffffff;
}
.cid-card__body--list > *,
.cid-card__body--emphasis > * {
position: relative;
z-index: 1;
}
.cid-card__body--table {
padding: calc(2 * var(--cid-u));
border-radius: 0 0 calc(6 * var(--cid-u)) calc(6 * var(--cid-u));
background: var(--cid-border);
overflow: hidden;
}
/* ── Bullet badge ─────────────────────────────────────────────────────── */
.cid-badge {
box-sizing: border-box;
position: relative;
/* Confines the sheen's plus-lighter blending to the badge's own surface. */
isolation: isolate;
flex: 0 0 auto;
width: calc(20 * var(--cid-u));
height: calc(20 * var(--cid-u));
border-radius: 50%;
background: var(--cid-badge);
/* The white ring is an inset shadow, not a border: border-width is snapped by the
engine and a bordered background also fails to paint inside foreignObject. */
box-shadow:
inset 0 0 0 calc(1.093 * var(--cid-u)) #ffffff,
calc(1.093 * var(--cid-u)) calc(1.093 * var(--cid-u))
calc(2.186 * var(--cid-u)) rgba(0, 0, 0, 0.25);
}
/*
* The Source overlays a 25% white diagonal sheen on the badge.
*
* The Source gradient is userSpaceOnUse from (29.2028, 94.3404) to (43.7721, 114.161)
* on a badge of centre (36.5078, 104.822) and diameter 28.082 raw. Re-solved for CSS,
* whose gradient line is sized to the box, that vector is 143.682deg and its four Source
* stops (0 / 0.35 / 0.65 / 1) land at 17.467 / 39.398 / 58.196 / 80.126 percent.
*
* The Source composites it with `mix-blend-mode: plus-lighter`, which on a non-zero
* channel is materially brighter than normal alpha compositing (badge blue 0x41:
* 128.75 additive vs 112.5 normal), so the blend mode is reproduced rather than
* approximated. It is painted BEFORE the chevron, as in the Source, so the sheen
* passes under the white glyph instead of tinting it.
*/
.cid-badge::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: 50%;
background: linear-gradient(
143.682deg,
rgba(255, 255, 255, 0.25) 17.467%,
rgba(255, 255, 255, 0) 39.398%,
rgba(255, 255, 255, 0) 58.196%,
rgba(255, 255, 255, 0.25) 80.126%
);
mix-blend-mode: plus-lighter;
pointer-events: none;
}
.cid-badge__chevron {
position: absolute;
inset: 0;
z-index: 1;
width: calc(20 * var(--cid-u));
height: calc(20 * var(--cid-u));
filter: drop-shadow(
calc(1.093 * var(--cid-u)) calc(1.093 * var(--cid-u))
calc(3.278 * var(--cid-u)) rgba(0, 37, 89, 0.65)
);
}
/* ── Form 1a — list body ──────────────────────────────────────────────── */
.cid-card__body--list,
.cid-card__body--emphasis {
padding: calc(15.756 * var(--cid-u)) calc(14 * var(--cid-u))
calc(13.56 * var(--cid-u)) calc(14 * var(--cid-u));
}
/*
* 강조형 1's content area measures 80 u tall against the list form's own rule predicting
* 78.834 u. The stack above the remainder is fully constrained and font-independent —
* 11.756 u lead-in (confirmed by the badge, which centres on line 1), 28.488 u primary
* line, 0.542 u secondary offset, 28.488 u secondary line — so the 1.166 u difference
* lands entirely in the trailing pad: 10.726 u of content pad plus the 4 u frame.
* One Source sample, so REFERENCE_OBSERVATION, not a derived rule.
*/
.cid-card__body--emphasis {
padding-bottom: calc(14.726 * var(--cid-u));
}
.cid-item {
display: flex;
align-items: flex-start;
}
.cid-item + .cid-item {
margin-top: calc(7.512 * var(--cid-u));
}
.cid-item > .cid-badge {
margin-top: calc(4.244 * var(--cid-u));
}
.cid-item__text {
flex: 1 1 0;
margin-left: calc(10 * var(--cid-u));
min-width: 0;
}
.cid-item__primary {
margin: 0;
color: var(--cid-text);
font-size: calc(23.7 * var(--cid-u));
letter-spacing: calc(-0.90642333984375 * var(--cid-u));
font-weight: 700;
line-height: calc(28.488 * var(--cid-u));
}
/* 강조형 1 measures a 29.03 u advance from the primary line to the secondary one,
0.542 u more than the primary line-height. */
.cid-item__secondary {
margin: calc(0.542 * var(--cid-u)) 0 0;
color: var(--cid-text);
font-size: calc(21.49 * var(--cid-u));
letter-spacing: calc(-0.8260262229225852 * var(--cid-u));
font-weight: 700;
line-height: calc(28.488 * var(--cid-u));
}
.cid-item__line {
display: block;
}
/* ── Form 1b — definition body ────────────────────────────────────────── */
.cid-card__body--definition {
padding: calc(10 * var(--cid-u)) 0 0 0;
}
.cid-def {
margin-bottom: calc(12.21 * var(--cid-u));
}
.cid-def__chip {
box-sizing: border-box;
max-width: 100%;
display: flex;
align-items: center;
width: fit-content;
height: calc(28 * var(--cid-u));
margin-left: calc(8 * var(--cid-u));
padding-left: calc(4 * var(--cid-u));
padding-right: calc(81.8 * var(--cid-u));
/* information-box.md §5.3/§5.5: pill relation, radius = height / 2 = 14 u
(Confirmed across five chip widths). */
border-radius: calc(14 * var(--cid-u));
background: linear-gradient(
to right,
#dce7f6 29.54%,
rgba(245, 249, 255, 0) 100%
);
}
.cid-def__label {
margin-left: calc(4.44 * var(--cid-u));
color: #000000;
font-size: calc(20 * var(--cid-u));
font-weight: 700;
line-height: calc(19.12 * var(--cid-u));
white-space: nowrap;
-webkit-text-stroke: calc(2 * var(--cid-u)) #ffffff;
paint-order: stroke fill;
}
.cid-def__value {
margin: calc(2.12 * var(--cid-u)) 0 0 calc(36.5 * var(--cid-u));
color: #000000;
font-size: calc(21.79 * var(--cid-u));
font-weight: 500;
line-height: calc(32.049 * var(--cid-u));
}
.cid-def__line {
display: block;
}
.cid-def__bullets {
margin: calc(2.12 * var(--cid-u)) 0 0 0;
padding: 0;
list-style: none;
}
.cid-def__bullets > li {
position: relative;
margin-left: calc(36.5 * var(--cid-u));
color: #000000;
font-size: calc(21.79 * var(--cid-u));
font-weight: 500;
line-height: calc(32.049 * var(--cid-u));
}
.cid-def__bullets > li + li {
margin-top: calc(7.834 * var(--cid-u));
}
.cid-def__bullets > li::before {
content: '\2022';
position: absolute;
left: calc(-18.5 * var(--cid-u));
}
/* ── Form 2 — table body ──────────────────────────────────────────────── */
.cid-card--table {
--cid-header-size: calc(26.66 * var(--cid-u));
--cid-header-height: calc(39.231 * var(--cid-u));
--cid-header-radius: calc(5.5894 * var(--cid-u));
}
.cid-table {
display: grid;
grid-template-columns: calc(133.299 * var(--cid-u)) minmax(0, 1fr);
gap: calc(2 * var(--cid-u));
background: var(--cid-border);
}
.cid-table__row {
display: contents;
}
.cid-table__label {
display: flex;
align-items: center;
justify-content: center;
padding: 0 calc(4 * var(--cid-u));
background: #dce7f6;
color: #001a41;
font-size: calc(20 * var(--cid-u));
font-weight: 700;
line-height: calc(26.116 * var(--cid-u));
text-align: center;
}
/* Source 테이블형 rounds ONLY the last row's value cell, by 4 u, so the frame's own
6 u bottom-right curve stays visible through it. The bottom-left label cell is a
square rect in the Source and is left square here. */
.cid-table__row:last-child .cid-table__value {
border-bottom-right-radius: calc(4 * var(--cid-u));
}
.cid-table__value {
min-width: 0;
box-sizing: border-box;
padding: calc(8.5 * var(--cid-u)) calc(10 * var(--cid-u))
calc(9.19 * var(--cid-u)) calc(33.89 * var(--cid-u));
background: #ffffff;
}
.cid-table__item {
position: relative;
margin: 0;
color: #000000;
font-size: calc(21.79 * var(--cid-u));
font-weight: 500;
line-height: calc(26.116 * var(--cid-u));
}
.cid-table__item + .cid-table__item {
margin-top: calc(8.248 * var(--cid-u));
}
.cid-table__item::before {
content: '\2022';
position: absolute;
left: calc(-20.32 * var(--cid-u));
}
.cid-table__line {
display: block;
}
/* ── Form 3 — emphasis ────────────────────────────────────────────────── */
.cid-emphasis {
display: flex;
align-items: stretch;
width: fit-content;
max-width: 100%;
}
.cid-card--emphasis {
position: relative;
z-index: 1;
flex: 0 0 auto;
}
.cid-panel {
position: relative;
z-index: 0;
display: flex;
align-items: center;
box-sizing: border-box;
flex: 0 0 auto;
width: var(--cid-panel-width, calc(447.5 * var(--cid-u)));
min-width: 0;
margin: calc(0.178 * var(--cid-u)) 0 calc(0.178 * var(--cid-u)) calc(-4 * var(--cid-u));
padding-left: calc(20 * var(--cid-u));
font-family: var(--cid-font);
}
.cid-panel::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: calc(8.688 * var(--cid-u));
background: linear-gradient(90deg, #0054cc 0%, rgba(0, 27, 66, 0) 100%);
opacity: 0.85;
}
.cid-panel__arrow {
flex: 0 0 auto;
width: calc(53.676 * var(--cid-u));
height: calc(40.583 * var(--cid-u));
margin-right: calc(14.41 * var(--cid-u));
overflow: visible;
fill: #0044a4;
stroke: #ffffff;
stroke-width: calc(4 * var(--cid-u));
stroke-linejoin: miter;
paint-order: stroke fill;
filter: drop-shadow(0 calc(4 * var(--cid-u)) calc(4 * var(--cid-u)) rgba(0, 0, 0, 0.25));
}
/* With no arrow the text sits 21.23 u from the panel's left edge (강조형2). */
.cid-panel:not(:has(.cid-panel__arrow)) {
padding-left: calc(21.23 * var(--cid-u));
}
.cid-panel__text {
min-width: 0;
max-width: 100%;
margin: 0;
color: #ffffff;
font-size: var(--cid-emphasis-size, calc(33.742 * var(--cid-u)));
font-weight: 700;
line-height: calc(36.322 * var(--cid-u));
white-space: nowrap;
-webkit-text-stroke: calc(2.896 * var(--cid-u)) #0054cc;
paint-order: stroke fill;
}
.cid-panel__line {
display: block;
}
/* ── Mini table (강조형2 left card) ───────────────────────────────────── */
.cid-card__body--emphasis:has(.cid-metrics) {
padding: calc(4 * var(--cid-u));
}
.cid-metrics {
display: grid;
grid-template-columns: calc(91.22 * var(--cid-u)) minmax(0, 1fr);
gap: calc(2 * var(--cid-u));
background: var(--cid-border);
}
.cid-metrics__row {
display: contents;
}
.cid-metrics__label {
display: flex;
align-items: center;
justify-content: center;
height: calc(37.093 * var(--cid-u));
background: #dce7f6;
color: #001a41;
font-size: calc(20 * var(--cid-u));
font-weight: 700;
line-height: calc(19.12 * var(--cid-u));
}
/* Information-only renderer calibration, not Canonical size/weight/tracking tokens.
* Source-supported Hangul ink: term/table label 18.52 u, metric label 18.58 u,
* primary 22.22 u, secondary 20.19 u (existing ±3% height / ±2% width gates).
* Controlled probes under the resolved Noto face established the sizes above.
* Term/label tracking preserves the previous Hangul advance; word spacing preserves
* the previous space advance. Primary/secondary tracking preserves the complete
* representative Source-string advance, including spaces and mixed-script content.
* Fixed line boxes and all existing component geometry remain unchanged.
* Evidence: verification/center-information-typography-remediation/.
* Font binary approval and complete role mappings remain TBD.
*/
.cid-def__label,
.cid-table__label,
.cid-metrics__label {
letter-spacing: calc(-0.809600830078125 * var(--cid-u));
word-spacing: calc(0.563201904296875 * var(--cid-u));
}
.cid-metrics__value {
min-width: 0;
display: flex;
align-items: center;
height: calc(37.093 * var(--cid-u));
padding-left: calc(10.667 * var(--cid-u));
background: #ffffff;
color: #001a41;
font-size: calc(21.05 * var(--cid-u));
font-weight: 400;
line-height: 1;
white-space: pre;
}
/* Source-confirmed weight split: only the result value and the delta are bold. */
.cid-metrics__from {
font-weight: 400;
}
.cid-metrics__to {
font-weight: 700;
}
.cid-metrics__delta {
color: #ff0000;
font-weight: 700;
}
/* User-confirmed overflow policy. Each authored line stays a separate line;
long dynamic strings do not create extra rows or expand an owned region. */
.cid-item__line,
.cid-def__line,
.cid-def__label,
.cid-def__bullet-line,
.cid-table__line,
.cid-table__label > span,
.cid-metrics__label > span,
.cid-metrics__run,
.cid-panel__line {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cid-def__value,
.cid-table__label,
.cid-metrics__label { min-width: 0; }
/* ── Reference page demo chrome (not part of the Component contract) ───── */
.cid-demo {
display: grid;
gap: 24px;
}
.cid-demo__block > header {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.cid-demo__block > header strong {
color: var(--color-action-primary);
font-size: 14px;
line-height: 20px;
}
.cid-demo__block > header span {
color: var(--color-fg-secondary);
font-size: 12px;
line-height: 18px;
}
.cid-demo__stage {
display: flex;
flex-wrap: wrap;
gap: 28px;
align-items: flex-start;
padding: 20px 20px 24px;
border: 1px solid var(--color-stroke-subtle);
border-radius: 4px;
background: #eceff3;
overflow-x: auto;
}
.cid-demo__state {
display: grid;
gap: 8px;
justify-items: start;
}
.cid-demo__tag {
color: var(--color-fg-secondary);
font-size: 11px;
letter-spacing: 0.08em;
}
@@ -0,0 +1,445 @@
import { useId, type CSSProperties, type ReactNode } from 'react';
import './CenterInformationDisplay.css';
/*
* CENTER Information Display — React Reference Implementation.
*
* This is a Reference Implementation, not a Canonical Contract. It exists to verify
* the geometry, typography, surface and dynamic-content rules extracted in
* verification/center-information-display/SOURCE-ANALYSIS.md against the Source SVGs.
*
* It has ZERO runtime dependency on the Source assets: no <img>, no background-image,
* no embedded Source SVG. The only two inline vector shapes (bullet chevron, emphasis
* arrow) are reconstructed from normalised coordinates that are published in the
* Contract Proposal, so any renderer can redraw them from the document alone.
*
* All geometry is expressed in design units (u). 1 u is set by --cid-u in the CSS.
*/
/* Normalised bullet chevron, drawn inside the 20 x 20 u badge box. Contract §15.1. */
const CHEVRON_PATH =
'M14.632 9.81C14.733 9.916 14.733 10.083 14.632 10.189L8.722 16.326C8.615 16.438 8.436 16.438 '
+ '8.328 16.326L7.118 15.069C7.016 14.963 7.016 14.795 7.118 14.69L11.451 10.189C11.553 10.083 '
+ '11.553 9.916 11.451 9.81L7.118 5.309C7.017 5.203 7.017 5.036 7.118 4.93L8.328 3.673C8.436 '
+ '3.561 8.615 3.561 8.722 3.673L14.632 9.81Z';
/* Normalised emphasis arrow, drawn inside a 53.676 x 40.583 u box. Contract §15.2. */
const ARROW_POINTS = '53.676,20.291 24.547,40.583 24.547,32.006 0,32.006 0,8.576 24.547,8.576 24.547,0';
export const CENTER_INFORMATION_PALETTES = Object.freeze({
navy: Object.freeze({
label: "주 색상 · Navy",
sourceFile: '박스형 설명2.svg',
headerFrom: '#012F67', headerTo: '#001A41',
border: '#98A4B2', badge: '#001A41', text: '#001A41',
}),
teal: Object.freeze({
label: "보조 · Teal",
sourceFile: '박스형 설명 보조컬러1.svg',
headerFrom: '#087577', headerTo: '#003A3B',
border: '#809FA0', badge: '#004748', text: '#004748',
}),
orange: Object.freeze({
label: "보조 · Orange",
sourceFile: '박스형 설명 보조 컬러 2.svg',
headerFrom: '#B55400', headerTo: '#6F3005',
border: '#D3BCA7', badge: '#7B3704', text: '#6C2D00',
}),
violet: Object.freeze({
label: "보조 · Violet",
sourceFile: '박스형 설명 보조 컬러3.svg',
headerFrom: '#7056A0', headerTo: '#492F79',
border: '#C2B8D5', badge: '#3B2368', text: '#3B2368',
}),
/* The 강조형 and 테이블형 cards use the navy header with a different, darker border. */
navyStrong: Object.freeze({
label: "주 색상 · Navy (강한 테두리)",
sourceFile: '강조형 1.svg / 테이블형.svg',
headerFrom: '#012F67', headerTo: '#001A41',
border: '#5D7A9D', badge: '#001A41', text: '#001A41',
}),
});
export type CenterInformationPalette = keyof typeof CENTER_INFORMATION_PALETTES;
/** list item: lines (+ optional secondaryLines). definition item: label (+ lines / bullets). */
export type CenterInformationBoxItem = { label?: string; lines?: string[]; secondaryLines?: string[]; bullets?: string[] };
export type CenterInformationListItem = { lines: string[]; secondaryLines?: string[] };
export type CenterInformationTableRow = { label: string; items: string[][] };
export type CenterInformationMetric = { label: string; from: string; to: string; delta: string; direction?: 'up' | 'down' };
function paletteVars(palette: CenterInformationPalette): CSSProperties {
const p = CENTER_INFORMATION_PALETTES[palette] ?? CENTER_INFORMATION_PALETTES.navy;
return {
'--cid-header-from': p.headerFrom,
'--cid-header-to': p.headerTo,
'--cid-border': p.border,
'--cid-badge': p.badge,
'--cid-text': p.text,
} as CSSProperties;
}
function BulletBadge() {
return (
<span className="cid-badge" aria-hidden="true">
<svg className="cid-badge__chevron" viewBox="0 0 20 20" focusable="false">
<path d={CHEVRON_PATH} fill="#FFFFFF" />
</svg>
</span>
);
}
function CardHeader({ title }: { title: string }) {
return <div className="cid-card__header"><span>{title}</span></div>;
}
/* ── Form 1 — 박스형 / Box ─────────────────────────────────────────────── */
/**
* layout="list" items: [{ lines: string[], secondaryLines?: string[] }]
* layout="definition" items: [{ label: string, lines?: string[], bullets?: string[] }]
*/
export function CenterInformationBox({
title,
palette = 'navy',
layout = 'list',
items = [],
width,
className = '',
}: {
title: string;
palette?: CenterInformationPalette;
layout?: 'list' | 'definition';
items?: CenterInformationBoxItem[];
width?: string;
className?: string;
}) {
const style = { ...paletteVars(palette), ...(width ? { '--cid-card-width': width } : null) } as CSSProperties;
return (
<section
className={`cid-card cid-card--${layout} ${className}`.trim()}
style={style}
aria-label={title}
>
<CardHeader title={title} />
<div className={`cid-card__body cid-card__body--${layout}`}>
{layout === 'definition'
? items.map((item, i) => (
<div className="cid-def" key={i}>
<div className="cid-def__chip">
<BulletBadge />
<span className="cid-def__label">{item.label}</span>
</div>
{item.lines?.length ? (
<p className="cid-def__value">
{item.lines.map((line, j) => (
<span className="cid-def__line" key={j}>{line}</span>
))}
</p>
) : null}
{item.bullets?.length ? (
<ul className="cid-def__bullets">
{item.bullets.map((line, j) => <li key={j}><span className="cid-def__bullet-line">{line}</span></li>)}
</ul>
) : null}
</div>
))
: items.map((item, i) => (
<div className="cid-item" key={i}>
<BulletBadge />
<div className="cid-item__text">
<p className="cid-item__primary">
{(item.lines ?? []).map((line, j) => (
<span className="cid-item__line" key={j}>{line}</span>
))}
</p>
{item.secondaryLines?.length ? (
<p className="cid-item__secondary">
{item.secondaryLines.map((line, j) => (
<span className="cid-item__line" key={j}>{line}</span>
))}
</p>
) : null}
</div>
</div>
))}
</div>
</section>
);
}
/* ── Form 2 — 테이블형 / Table ─────────────────────────────────────────── */
/** rows: [{ label: string, items: string[][] }] — each item is an array of lines. */
export function CenterInformationTable({ title, rows = [], width, className = '' }: { title: string; rows?: CenterInformationTableRow[]; width?: string; className?: string }) {
const style = { ...paletteVars('navyStrong'), ...(width ? { '--cid-card-width': width } : null) } as CSSProperties;
return (
<section className={`cid-card cid-card--table ${className}`.trim()} style={style} aria-label={title}>
<CardHeader title={title} />
<div className="cid-card__body cid-card__body--table">
<div className="cid-table" role="table">
{rows.map((row, i) => (
<div className="cid-table__row" role="row" key={i}>
<div className="cid-table__label" role="rowheader"><span>{row.label}</span></div>
<div className="cid-table__value" role="cell">
{row.items.map((lines, j) => (
<p className="cid-table__item" key={j}>
{lines.map((line, k) => (
<span className="cid-table__line" key={k}>{line}</span>
))}
</p>
))}
</div>
</div>
))}
</div>
</div>
</section>
);
}
/* ── Form 3 — 강조형 / Emphasis ────────────────────────────────────────── */
/**
* Left card body is either a list (`items`) or a mini table (`metrics`).
* metrics: [{ label, from, to, delta, direction: 'up'|'down' }]
* Rendered as: from → to(▲delta) — the result value and the delta are emphasised,
* the from-value is not. This weight split is Source-confirmed (강조형2).
* emphasis: string[] — one entry per emphasis line.
*/
export function CenterInformationEmphasis({
title,
items,
metrics,
emphasis = [],
arrow = false,
emphasisSize,
cardWidth,
panelWidth,
className = '',
}: {
title: string;
items?: CenterInformationListItem[];
metrics?: CenterInformationMetric[];
emphasis?: string[];
arrow?: boolean;
emphasisSize?: string;
cardWidth?: string;
panelWidth?: string;
className?: string;
}) {
const style = {
...paletteVars('navyStrong'),
...(cardWidth ? { '--cid-card-width': cardWidth } : null),
...(panelWidth ? { '--cid-panel-width': panelWidth } : null),
...(emphasisSize ? { '--cid-emphasis-size': emphasisSize } : null),
} as CSSProperties;
const accessibleEmphasis = emphasis.join(' ');
/* Unique per instance: several arrows can share one page. */
const sheenId = `cid-arrow-sheen-${useId().replace(/:/g, '')}`;
return (
<div className={`cid-emphasis ${className}`.trim()} style={style}>
<section className="cid-card cid-card--emphasis" aria-label={title}>
<CardHeader title={title} />
<div className="cid-card__body cid-card__body--emphasis">
{metrics ? (
<div className="cid-metrics" role="table">
{metrics.map((m, i) => (
<div className="cid-metrics__row" role="row" key={i}>
<div className="cid-metrics__label" role="rowheader"><span>{m.label}</span></div>
<div className="cid-metrics__value" role="cell">
<span className="cid-metrics__run">
<span className="cid-metrics__from">{m.from} → </span>
<span className="cid-metrics__to">{m.to}(</span>
<span className="cid-metrics__delta">
{m.direction === 'down' ? '▼' : '▲'}{m.delta}
</span>
<span className="cid-metrics__to">)</span>
</span>
</div>
</div>
))}
</div>
) : (
(items ?? []).map((item, i) => (
<div className="cid-item" key={i}>
<BulletBadge />
<div className="cid-item__text">
<p className="cid-item__primary">
{item.lines.map((line, j) => <span className="cid-item__line" key={j}>{line}</span>)}
</p>
{item.secondaryLines?.length ? (
<p className="cid-item__secondary">
{item.secondaryLines.map((line, j) => (
<span className="cid-item__line" key={j}>{line}</span>
))}
</p>
) : null}
</div>
</div>
))
)}
</div>
</section>
<div className="cid-panel" role="note" aria-label={accessibleEmphasis}>
{arrow ? (
<svg className="cid-panel__arrow" viewBox="0 0 53.676 40.583" focusable="false" aria-hidden="true">
<defs>
{/*
Source 강조형 1 paints a second copy of the arrow over the solid fill with a
white gradient at fill-opacity 0.2, running from the arrow's RIGHT edge
(x1 = 655.886, the tip) leftward to alpha 0 at 55%. Reconstructed from the
Source's own stops; no Source asset is referenced.
*/}
<linearGradient id={sheenId} x1="53.676" y1="0" x2="0" y2="0"
gradientUnits="userSpaceOnUse">
<stop stopColor="#FFFFFF" />
<stop offset="0.55" stopColor="#FFFFFF" stopOpacity="0" />
</linearGradient>
</defs>
<polygon points={ARROW_POINTS} />
<polygon points={ARROW_POINTS} fill={`url(#${sheenId})`} fillOpacity="0.2" stroke="none" />
</svg>
) : null}
<p className="cid-panel__text" aria-hidden="true">
{emphasis.map((line, i) => <span className="cid-panel__line" key={i}>{line}</span>)}
</p>
</div>
</div>
);
}
/* ── Reference page demo ──────────────────────────────────────────────── */
const SOURCE_LIST_ITEMS = [
{ lines: ['과천 지식정보타운과 과천 기존 생활권의 연결지'] },
{ lines: ['제2경인고속도로, 4호선'] },
{ lines: ['광역교통 확충 예정 (GTX-C, 경강선(시흥-성남),', '과천 위례선)'] },
];
const CONTENT_STATES = [
{ key: 'short', label: 'SHORT', title: '입지', items: [{ lines: ['연결지'] }] },
{ key: 'reference', label: 'REFERENCE', title: '광역 교통 중심지 내 입지', items: SOURCE_LIST_ITEMS },
{
key: 'long',
label: 'LONG',
title: '광역 교통 중심지 내 입지 및 연계 교통망 확충 계획 검토',
items: [
{ lines: ['과천 지식정보타운과 과천 기존 생활권의 연결지이며 광역 환승 거점으로 기능하는 지역이다'] },
{ lines: ['제2경인고속도로, 4호선, 위례과천선, 경강선 연장 구간 및 광역급행철도 접속 예정'] },
],
},
{ key: 'extreme', label: 'EXTREME LONG', title: '극단적으로 긴 동적 사업 정보 '.repeat(24), items: [{ lines: ['가용 콘텐츠 영역을 초과하는 동적 정보 '.repeat(40)] }] },
];
function Block({ title, note, children }: { title: string; note?: string; children?: ReactNode }) {
return (
<section className="cid-demo__block">
<header>
<strong>{title}</strong>
{note ? <span>{note}</span> : null}
</header>
<div className="cid-demo__stage cid-scope">{children}</div>
</section>
);
}
export function CenterInformationDisplayReferenceDemo() {
return (
<div className="cid-demo">
<Block
title="박스형 · 정의 목록 (박스형 설명1)"
note="배지 + 그라데이션 레이블 칩 + 값 블록; 하위 불릿은 값 열 유지"
>
<CenterInformationBox
layout="definition"
palette="navy"
title="과업내용"
width="498px"
items={[
{ label: '사업명', lines: ['주암댐 광역 노후관 개량사업(1차, 광주계통)', '기본 및 실시 설계 용역'] },
{ label: '사업의 위치', lines: ['전라남도 순천시, 화순군 일원'] },
{ label: '급수대상지역', lines: ['광주광역시, 전라남도 목포시, 나주시, 화순군 일원'] },
{ label: '과업내용', bullets: ['노후관 개량 : D1,800~D2,800 / L=22.1km', '대체관로(신설 : D2,200 / L=1.2km)'] },
{ label: '과업기간', lines: ['착수일로부터 24개월 (720일간)'] },
]}
/>
</Block>
<Block title="박스형 · 목록 — Source 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
{['navy', 'teal', 'orange', 'violet'].map((palette) => (
<CenterInformationBox
key={palette}
palette={palette as CenterInformationPalette}
title="광역 교통 중심지 내 입지"
width="509px"
items={SOURCE_LIST_ITEMS}
/>
))}
</Block>
<Block title="테이블형" note="행 높이 = 기본값 + 추가 행마다 26.116 u + 추가 항목마다 34.364 u">
<CenterInformationTable
title="사업효과"
width="643px"
rows={[
{ label: '과업개요', items: [['기간 : 2026년 ~2028년 (3년간)'], ['대상 : 동작대교~행주대교(총 10개소)']] },
{
label: '주요 과업내용',
items: [
['일상 유지관리 : 연간 일상보수 및 긴급 조치 대응 (연간단가공사)'],
['성능개선 사업 : 내진보강 등 정밀 안전 진단 결과 반영 공사 관리'],
],
},
{ label: '용역기간', items: [['착수일부터 36개월']] },
{ label: '발주처', items: [['서울특별시(재난안전실 교량안전과)']] },
]}
/>
</Block>
<Block title="강조형 · 화살표 표시 (강조형 1)" note="패널은 카드 뒤에 4 u 겹침으로 배치되며 투명하게 사라짐">
<CenterInformationEmphasis
title="사업목적"
arrow
cardWidth="398px"
panelWidth="448px"
items={[{
lines: ['수도권과 접근성을 향상'],
secondaryLines: ['수출 경쟁력 강화 및 지역 균형발전 촉진'],
}]}
emphasis={['대산~당진 고속도로 건설']}
/>
</Block>
<Block title="강조형 · 수치 소형 테이블 (강조형2)" note="변화값은 색상과 ▲/▼ 방향 표시를 함께 사용">
<CenterInformationEmphasis
title="사업효과"
cardWidth="409px"
panelWidth="488px"
emphasisSize="calc(28.033 * var(--cid-u))"
metrics={[
{ label: '이동거리', from: '38km', to: '25.4km', delta: '12.6km', direction: 'up' },
{ label: '이동시간', from: '35분', to: '15분', delta: '20분', direction: 'down' },
]}
emphasis={['수도권과 접근성을 향상', '수출 경쟁력 강화 및 지역 균형발전 촉진']}
/>
</Block>
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="소유 단일행은 말줄임으로 클리핑; 명시적 줄바꿈 유지; 장문이 지오메트리를 늘리지 않음">
{CONTENT_STATES.map((state) => (
<div className="cid-demo__state" key={state.key} data-content-test={state.key}>
<span className="cid-demo__tag">{state.label}</span>
<CenterInformationBox palette="teal" title={state.title} width="420px" items={state.items} />
<CenterInformationBox layout="definition" title={state.title} width="420px" items={[{ label: '사업명', lines: [state.items[0].lines[0]] }]} />
<CenterInformationTable title={state.title} width="420px" rows={[{ label: '과업개요', items: [[state.items[0].lines[0]]] }]} />
<CenterInformationEmphasis title={state.title} cardWidth="420px" panelWidth="448px" items={[{ lines: [state.items[0].lines[0]] }]} emphasis={[state.items[0].lines[0]]} />
</div>
))}
</Block>
</div>
);
}
@@ -0,0 +1,22 @@
.center-representation { min-width: 0; }
.center-representation__note { color: var(--color-fg-secondary); font-size: 12px; margin: 0 0 16px; }
/* Per-representation sizing. Reference shell/demo layout is not a scaled canvas.
zoom is a scoped CSS implementation choice, not a Canonical browser setting.
Existing Source-unit calibrations / specimen densities are kept independent. */
.center-representation .center-title,
.center-representation .tunnel-facility:not(.tunnel-applied-example .tunnel-facility),
.center-representation .local-facility:not(.facility-applied .local-facility),
.center-representation .roadside-facility-tag:not(.facility-applied .roadside-facility-tag),
.center-representation .tunnel-applied-example,
.center-representation .facility-applied,
.center-representation .cid-card:not(.cid-emphasis .cid-card),
.center-representation .cid-emphasis {
zoom: var(--center-representation-scale);
}
.center-representation .csi { --csi-u: calc(1px * var(--center-representation-scale)); }
.center-representation .csi-scale-50 { --csi-u: calc(0.5px * var(--center-representation-scale)); }
.center-representation .csi-scale-40 { --csi-u: calc(0.4px * var(--center-representation-scale)); }
.center-representation .csi-scale-32 { --csi-u: calc(0.32px * var(--center-representation-scale)); }
.center-representation .csi-scale-22 { --csi-u: calc(0.22px * var(--center-representation-scale)); }
@@ -0,0 +1,23 @@
import type { CSSProperties, ReactNode } from 'react';
import './CenterRepresentation.css';
// The authoring target is supplied by the caller. The browser and Storybook iframe
// dimensions never select the graphic scale.
export type CenterTargetResolution = '7680x2160' | '3840x2160';
export function CenterRepresentation({ children, targetResolution = '7680x2160' }: { children?: ReactNode; targetResolution?: CenterTargetResolution }) {
const isCompact = targetResolution === '3840x2160';
const scale = isCompact ? 0.75 : 1;
const resolution = isCompact ? '3840x2160' : '7680x2160';
return (
<div className="center-representation" data-representation-scale={scale}
data-resolution={resolution} data-scale-status="CONFIRMED_PAIR"
style={{ '--center-representation-scale': scale } as CSSProperties}>
<p className="center-representation__note">
표시 배율 {scale * 100}% · 제작 대상 {isCompact ? '3840' : '7680'} × 2160
</p>
{children}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,633 @@
import { useMemo, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react';
import './CenterSpatialInformation.css';
/*
* CENTER Spatial Information Representation (공간정보 표현)
* React Reference Implementation.
*
* This is a Reference Implementation, NOT a Canonical Contract. It exists to
* verify the geometry, typography, surface and dynamic-content rules extracted
* in verification/center-spatial-information/SOURCE-ANALYSIS.md against the
* Source SVGs and 시안 mockups.
*
* Runtime dependency on the Source assets is ZERO:
* - no <img>, no background-image, no embedded Source SVG file,
* - every text string is real DOM text, never an outlined glyph path,
* - every surface, outline, dash, hatch and marker is rebuilt from the
* primitives published in the Contract Proposal.
*
* The only inline vector shapes are the two 방위 needles (reconstructed from
* 4-point polygons, expressed as clip-path in CSS) and the 고속도로 route-number
* shield, which is irreducible symbol geometry. Its normalised path data is
* published in the Contract Proposal §12.4 so any renderer can redraw it from
* the document alone.
*
* All geometry is expressed in design units (u); 1u is set by --csi-u.
*/
/* ------------------------------------------------------------------
* Route classes — Source: 노선 유형별 구분 + 노선 정보 표기
* ------------------------------------------------------------------ */
export const ROUTE_CLASSES = Object.freeze({
expressway: Object.freeze({
key: 'expressway', label: '고속도로', modifier: 'expressway',
line: '#0D6199', casing: 'light', numberShape: 'shield', sampleName: '고속도로', sampleNumber: '66',
}),
national: Object.freeze({
key: 'national', label: '국도', modifier: 'national',
line: '#203E6F', casing: 'light', numberShape: 'circle', sampleName: '국도', sampleNumber: '32',
}),
provincial: Object.freeze({
key: 'provincial', label: '지방도', modifier: 'provincial',
line: '#DBB860', casing: 'dark', numberShape: 'rect', sampleName: '지방도', sampleNumber: '123',
}),
metropolitan: Object.freeze({
key: 'metropolitan', label: '특별시도 · 광역시도', modifier: 'metropolitan',
line: '#A862C9', casing: 'light', numberShape: null, sampleName: '특별시도', sampleNumber: null,
}),
cityCounty: Object.freeze({
key: 'cityCounty', label: '시도 · 군도', modifier: 'city-county',
line: '#499A02', casing: 'light', numberShape: 'hexagon', sampleName: '시도/군도', sampleNumber: '66',
}),
other: Object.freeze({
key: 'other', label: '구도 · 면도 · 이도 (기타도로)', modifier: 'other',
line: '#FFFFFF', casing: 'dark', numberShape: null, sampleName: '구도·면도·이도', sampleNumber: null,
}),
railway: Object.freeze({
key: 'railway', label: '철도선', modifier: 'railway',
line: '#333333', casing: 'light', numberShape: null, sampleName: '철도선', sampleNumber: null,
}),
});
export type RouteClassKey = keyof typeof ROUTE_CLASSES;
export const ROUTE_STATES = Object.freeze(['existing', 'underConstruction', 'planned'] as const);
export type RouteState = (typeof ROUTE_STATES)[number];
const STATE_LABEL: Record<RouteState, string> = { existing: '기본', underConstruction: '공사중', planned: '계획(예정)' };
/* ------------------------------------------------------------------
* 1. 노선 — Route line
* ------------------------------------------------------------------ */
export function RouteLine({ routeClass = 'national', state = 'existing', shadow = true, style, ...rest }: {
routeClass?: RouteClassKey;
state?: RouteState;
shadow?: boolean;
style?: CSSProperties;
} & Omit<HTMLAttributes<HTMLDivElement>, 'style'>) {
const cls = ROUTE_CLASSES[routeClass] ?? ROUTE_CLASSES.national;
const isRailway = cls.key === 'railway';
const core = (
<div
className={[
'csi-route',
`csi-route--${cls.modifier}`,
state === 'underConstruction' ? 'csi-route--under-construction' : '',
state === 'planned' ? 'csi-route--planned' : '',
shadow ? '' : 'csi-route--no-shadow',
].filter(Boolean).join(' ')}
style={isRailway ? undefined : style}
{...(isRailway ? {} : rest)}
>
{isRailway ? <span className="csi-railway-dash" /> : null}
</div>
);
/* 철도선 계획 / 공사중 swap the solid casing for a dashed casing. */
if (isRailway && state !== 'existing') {
return (
<div
className={`csi-route-dashed-casing csi-route-dashed-casing--${state} csi-route--railway`}
style={{ '--csi-dash': state === 'planned' ? 2 : 6, ...style } as CSSProperties}
{...rest}
>
{core}
</div>
);
}
return core;
}
/* 사업노선 — project route: a #D21203 line terminated by circular markers. */
export function ProjectRoute({ markers = true, style, ...rest }: { markers?: boolean; style?: CSSProperties } & Omit<HTMLAttributes<HTMLDivElement>, 'style'>) {
return (
<div className="csi-project-route" style={style} {...rest}>
{markers ? <span className="csi-route-marker" /> : null}
<RouteLine routeClass="national" state="existing" style={{ '--csi-core': '#D21203' } as CSSProperties} />
{markers ? <span className="csi-route-marker" /> : null}
</div>
);
}
/* ------------------------------------------------------------------
* 2. 노선 정보 표기 — Route information labelling (DYNAMIC TEXT)
* ------------------------------------------------------------------ */
/* 2.1 노선명 — route name. Content-driven width, fixed 54u height. */
export function RouteNameBadge({ routeClass = 'national', name, lang = 'ko' }: { routeClass?: RouteClassKey; name?: string | null; lang?: string }) {
const cls = ROUTE_CLASSES[routeClass] ?? ROUTE_CLASSES.national;
return (
<span className={`csi-route-name csi-route-name--${cls.modifier}`} lang={lang}>
<span>{name ?? cls.sampleName}</span>
</span>
);
}
/* Irreducible symbol geometry — 고속도로 route-number shield.
* Contract §12.4. Coordinates are the Source coordinates of the 64u badge;
* the viewBox crops to the shield's own visual bounds so the element box is
* exactly 64u x 71.2u. */
const SHIELD_VIEWBOX = '8 4.29 64.001 71.201';
const SHIELD_BODY =
'M64.9492 26.85H14.9492L15.5492 30.7L11.2492 42L10.6992 56.1L14.6492 64.6L26.5992 69.1L39.4992 '
+ '73.2L43.0992 71.3L62.2492 67L69.3492 59.15L70.2492 48.35L65.7492 35.95L64.9492 26.85Z';
const SHIELD_BAND =
'M30.5004 9.55C27.7337 8.61667 22.0904 6.75 21.6504 6.75L10.1504 16.85L15.3504 27.95H64.8504L69.2004 '
+ '18.1L58.6004 6.75L49.1504 8.8L40.5504 6.75L30.5004 9.55Z';
const SHIELD_OUTLINE =
'M40 5.88965C44.9961 8.86249 49.9279 8.81213 53.627 7.98535C55.5783 7.54917 57.1987 6.89594 58.334 '
+ '6.35156C58.5823 6.2325 58.8079 6.11693 59.0098 6.01074L69.792 17.2461C69.7586 17.3002 69.7227 '
+ '17.3556 69.6875 17.4131C69.2122 18.1904 68.5776 19.2813 67.9414 20.5361C66.7072 22.9706 65.3185 '
+ '26.3013 65.3184 29.1875C65.3184 34.1169 66.7289 36.9271 67.9824 39.9297C69.1936 42.8307 70.4004 '
+ '46.1823 70.4004 52.5635C70.4004 57.5169 68.9507 60.7782 66.8076 63.0762C64.621 65.4207 61.5729 '
+ '66.9135 58.1133 68.0381C54.5896 69.1835 51.0175 69.8475 47.5273 70.7656C44.775 71.4896 42.0405 '
+ '72.3819 40 73.8906C37.9595 72.3819 35.225 71.4896 32.4727 70.7656C28.9825 69.8475 25.4104 69.1835 '
+ '21.8867 68.0381C18.4271 66.9135 15.379 65.4207 13.1924 63.0762C11.0493 60.7782 9.59961 57.5169 '
+ '9.59961 52.5635C9.59965 46.1823 10.8064 42.8307 12.0176 39.9297C13.2711 36.9271 14.6816 34.1169 '
+ '14.6816 29.1875C14.6815 26.3013 13.2928 22.9706 12.0586 20.5361C11.4224 19.2813 10.7878 18.1904 '
+ '10.3125 17.4131C10.2772 17.3554 10.2405 17.3003 10.207 17.2461L20.9893 6.01074C21.1913 6.11705 '
+ '21.4174 6.23235 21.666 6.35156C22.8013 6.89594 24.4217 7.54917 26.373 7.98535C30.0721 8.81213 '
+ '35.0039 8.86249 40 5.88965Z';
/* 2.2 노선번호 — route number. Fixed 64u-base symbol, dynamic numeric text. */
export function RouteNumberBadge({ routeClass = 'national', number }: { routeClass?: RouteClassKey; number?: string | null }) {
const cls = ROUTE_CLASSES[routeClass] ?? ROUTE_CLASSES.national;
const shape = cls.numberShape;
if (!shape) return null;
const value = number ?? cls.sampleNumber;
return (
<span className={`csi-route-number csi-route-number--${shape}`}>
{shape === 'hexagon' ? (
<>
<span className="csi-hex" aria-hidden="true" />
<span className="csi-hex-rule" aria-hidden="true" />
</>
) : null}
{shape === 'shield' ? (
<svg viewBox={SHIELD_VIEWBOX} aria-hidden="true" focusable="false">
<path d={SHIELD_BODY} fill="#0043B3" />
<path d={SHIELD_BAND} fill="#BF0000" />
<path d={SHIELD_OUTLINE} fill="none" stroke="#FFFFFF" strokeWidth="3.2" strokeLinejoin="round" />
<path d="M14.8477 27.1836H65.1504" stroke="#FFFFFF" strokeWidth="3.2" strokeLinejoin="round" />
</svg>
) : null}
<span className="csi-number-text">{value}</span>
</span>
);
}
/* ------------------------------------------------------------------
* 3. 방면 표기 — Direction (bound-for) badge
* ------------------------------------------------------------------ */
/* Reconstructed arrow, Contract §13.2: a 7-point polygon in a 19.615 x 16.092u
* box; the shaft is 5u tall and the head spans the full box height. */
export type DirectionKind = 'start' | 'end';
function DirectionArrow({ direction }: { direction: DirectionKind }) {
const flip = direction === 'start';
return (
<svg
className="csi-direction-arrow"
viewBox="0 0 19.615 16.092"
width="19.615" height="16.092"
style={{
width: 'calc(19.615 * var(--csi-u))',
height: 'calc(16.092 * var(--csi-u))',
transform: flip ? 'scaleX(-1)' : undefined,
}}
aria-hidden="true" focusable="false"
>
<polygon
points="0,5.547 9.708,5.547 9.708,0 19.615,8.046 9.708,16.092 9.708,10.547 0,10.547"
fill="currentColor"
/>
</svg>
);
}
export function DirectionBadge({ name = '서울', direction = 'end' }: { name?: string; direction?: DirectionKind }) {
const arrow = <DirectionArrow direction={direction} />;
return (
<span className="csi-direction">
{direction === 'start' ? arrow : null}
<span className="csi-direction-text">{name}</span>
{direction === 'end' ? arrow : null}
</span>
);
}
/* ------------------------------------------------------------------
* 4. 행정구역 표기 — Administrative district labelling
* ------------------------------------------------------------------ */
export const ADMIN_LEVELS = Object.freeze({
sigun: { key: 'sigun', modifier: 'sigun', label: '시 · 군', sample: '당진시' },
eupmyeondong: { key: 'eupmyeondong', modifier: 'eupmyeondong', label: '읍 · 면 · 동', sample: '남해군' },
ri: { key: 'ri', modifier: 'ri', label: '리', sample: '조금리' },
});
export type AdminLevelKey = keyof typeof ADMIN_LEVELS;
export function AdministrativeLabel({ level = 'sigun', form = 'plain', name }: { level?: AdminLevelKey; form?: 'plain' | 'tiles'; name?: string }) {
const lv = ADMIN_LEVELS[level] ?? ADMIN_LEVELS.sigun;
const text = name ?? lv.sample;
if (form === 'tiles') {
/* 강조형 renders one bevelled tile per character. */
return (
<span className={`csi-admin csi-admin--tiles csi-admin--${lv.modifier}`} aria-label={text}>
{Array.from(text).map((ch, i) => (
<span className="csi-admin-tile" key={`${ch}-${i}`} aria-hidden="true">
<span>{ch}</span>
</span>
))}
</span>
);
}
return (
<span className={`csi-admin csi-admin--plain csi-admin--${lv.modifier}`}>
<span>{text}</span>
</span>
);
}
/* ------------------------------------------------------------------
* 5. 방위 표기 — North arrow
* ------------------------------------------------------------------ */
export function NorthArrow({ variant = 'full', label = 'N', orientation = 0 }: { variant?: 'full' | 'simple'; label?: string; orientation?: number }) {
return (
<div className={`csi-north csi-north--${variant}`} role="img" aria-label={`방위 표시 ${label}, ${orientation}°`} data-orientation={orientation} style={{ '--csi-north-angle': `${orientation}deg` } as CSSProperties}>
<span className="csi-north-ring" aria-hidden="true" />
<span className="csi-north-direction" aria-hidden="true">
<span className="csi-north-needle" aria-hidden="true" />
<span className="csi-north-label" aria-hidden="true">{label}</span>
</span>
</div>
);
}
/* ------------------------------------------------------------------
* 6. 구역 표현 — Region boundary line
* ------------------------------------------------------------------ */
export function BoundaryLine({ form = 'solid', style }: { form?: 'solid' | 'dashed'; style?: CSSProperties }) {
return <div className={`csi-boundary csi-boundary--${form}`} style={style} />;
}
/* ------------------------------------------------------------------
* 7. 사업 시 · 종점 표기 — Project extent labelling
* ------------------------------------------------------------------ */
/* 세로형 STA. — the authored flag keeps the label on one line and then stacks the
* station value one character per line. Nothing is rotated. */
function VerticalStation({ sta }: { sta: string }) {
const [label, ...rest] = String(sta).trim().split(/\s+/);
return (
<>
<span className="csi-extent-vflag-label">{label}</span>
{Array.from(rest.join(' ')).map((ch, i) => <span key={i}>{ch}</span>)}
</>
);
}
export type ProjectExtentSection = { name: string; length: string; weight?: number };
export function ProjectExtent({
variant = 'basic',
projectName = '장암~민락 수락터널 민간투자사업',
totalLength = 'L=4.30km',
startSta = 'STA. 0+000',
endSta = 'STA. 0+000',
sections = [],
startRegion = ['김포시', '동진읍', '서앙리'],
endRegion = ['김포시', '양촌읍', '누산리'],
}: {
variant?: 'basic' | 'sections' | 'regions' | 'vertical';
projectName?: string;
totalLength?: string;
startSta?: string;
endSta?: string;
sections?: ProjectExtentSection[];
startRegion?: string[];
endRegion?: string[];
}) {
const withSections = variant === 'sections';
const withRegions = variant === 'regions';
if (variant === 'vertical') {
return (
<div className="csi-extent csi-extent--vertical">
<div className="csi-extent-vrail">
<span className="csi-extent-vaxis" aria-hidden="true" />
<div className="csi-extent-vflag csi-extent-vflag--start">
<VerticalStation sta={startSta} />
</div>
<div className="csi-extent-card">
<div className="csi-extent-title">{projectName}</div>
<div className="csi-extent-length">{totalLength}</div>
</div>
<div className="csi-extent-vflag csi-extent-vflag--end">
<VerticalStation sta={endSta} />
</div>
</div>
</div>
);
}
return (
<div
className={`csi-extent${withRegions ? ' csi-extent--regions' : ''}${withSections ? ' csi-extent--sections' : ''}`}
>
<div className="csi-extent-row">
{withRegions ? null : (
<span className="csi-extent-flag csi-extent-flag--start">{startSta}</span>
)}
<span className="csi-extent-spacer" />
{withRegions ? null : (
<span className="csi-extent-flag csi-extent-flag--end">{endSta}</span>
)}
</div>
<span className="csi-extent-arrow" aria-hidden="true" />
<div className="csi-extent-card">
<div className="csi-extent-title">{projectName}</div>
<div className="csi-extent-length">{totalLength}</div>
</div>
{withSections && sections.length > 0 ? (
<div className="csi-extent-sections">
{sections.map((s, i) => (
<div className="csi-extent-section" key={i} style={{ flex: `${s.weight ?? 1} 1 0` }}>
{i > 0 ? <span className="csi-extent-section-boundary" aria-hidden="true" /> : null}
<span className="csi-extent-section-pill">
<span>{s.name}</span>
<i aria-hidden="true" />
<span>{s.length}</span>
</span>
</div>
))}
</div>
) : null}
{withRegions ? (
<div className="csi-extent-region-row">
<div className="csi-extent-region csi-extent-region--start">
<span>{startRegion.map((level, i) => <span key={i}>{level}</span>)}</span>
</div>
<div className="csi-extent-region csi-extent-region--end">
<span>{endRegion.map((level, i) => <span key={i}>{level}</span>)}</span>
</div>
</div>
) : null}
</div>
);
}
/* ==================================================================
* Reference demo page
* ================================================================== */
type RouteClassEntry = (typeof ROUTE_CLASSES)[RouteClassKey];
const NAME_SAMPLES: Record<string, { label: string; get: (c: RouteClassEntry) => string }> = {
short: { label: 'Short', get: (c) => (c.key === 'cityCounty' ? '군도' : '국도') },
reference: { label: "기준 (Source 표본)", get: (c) => c.sampleName },
long: { label: 'Long', get: () => '서해안고속도로지선제2구간' },
};
const NUMBER_SAMPLES: Record<string, { label: string; get: (c: RouteClassEntry) => string | null }> = {
short: { label: 'Short', get: () => '1' },
reference: { label: "기준 (Source 표본)", get: (c) => c.sampleNumber },
long: { label: 'Long', get: () => '4885' },
};
function Panel({ id, title, legend, note, light, children }: { id: string; title: string; legend?: string; note?: string; light?: boolean; children?: ReactNode }) {
return (
<section id={id} className={`csi-panel${light ? ' csi-panel--light' : ''}`}>
<h4>{title}</h4>
{legend ? <p className="csi-legend">{legend}</p> : null}
{children}
{note ? <p className="csi-note">{note}</p> : null}
</section>
);
}
function Row({ label, children, top }: { label: string; children?: ReactNode; top?: boolean }) {
return (
<div className={`csi-row${top ? ' csi-row--top' : ''}`}>
<span className="csi-row-label">{label}</span>
<div className="csi-row-body">{children}</div>
</div>
);
}
export function CenterSpatialInformationReferenceDemo() {
const [content, setContent] = useState('reference');
const [routeName, setRouteName] = useState('');
const [routeNumber, setRouteNumber] = useState('');
const classList = useMemo(() => Object.values(ROUTE_CLASSES), []);
const nameFor = (c: RouteClassEntry) => (routeName.trim() ? routeName : NAME_SAMPLES[content].get(c));
const numberFor = (c: RouteClassEntry) => (routeNumber.trim() ? routeNumber : NUMBER_SAMPLES[content].get(c));
return (
<div className="csi csi-demo">
<Panel
id="csi-area-route" title="1. 노선 — 노선, 상태, 윤곽"
legend={
"중심선 18u, 윤곽은 중심선의 20% (3.6u), 그림자 X0 Y0 Blur 6u Opacity 70%. "
+ "지방도 · 구도 · 면도 · 이도는 Dark Outline (#000 / 30%), 나머지 분류는 White Outline 적용. "
+ "Source SVG를 로드하지 않음: 외피는 spread box-shadow, 공사중은 반복 파선 그라데이션, 계획은 45° 빗금 그라데이션."
}
note={
"KNOWN_DEVIATION — 구도,면도, 이도.svg는 흰색 윤곽으로 내보내며 명시된 Source 규칙과 시안 색상 견본에 모순"
+ "여기서는 명시된 규칙 구현"
}
>
<div className="csi-swatchgrid csi-scale-50">
{classList.map((c) => (
<div key={c.key}>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 10 }}>{c.label}</div>
{ROUTE_STATES.map((s) => (
<div key={s} style={{ margin: '0 0 18px', paddingLeft: 6 }}>
<RouteLine routeClass={c.key} state={s} />
<div style={{ fontSize: 11, color: 'rgba(255,255,255,.6)', marginTop: 7 }}>{STATE_LABEL[s]}</div>
</div>
))}
</div>
))}
</div>
</Panel>
<Panel
id="csi-area-project-route" title="1b. 사업 노선 — 사업노선과 끝 표시"
legend={"사업 노선 #D21203. 원형 표시 = 선의 150%인 원 (18u 선에서 27u), 노선 윤곽과 그림자 공유."}
>
<div className="csi-scale-50" style={{ padding: '10px 4px 4px' }}>
<ProjectRoute />
</div>
</Panel>
<Panel
id="csi-area-route-information" title="2. 노선 정보 표기 — 노선번호와 노선명 (동적 텍스트)"
legend={
'노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다. '
+ "아래 문자열은 모두 실제 DOM 텍스트다. Source SVG에는 윤곽 글리프 경로로 기록되어 있지만 해당 지오메트리는 런타임에서 사용하지 않음. "
+ "노선명 너비 = padding 9u + 고유 텍스트 너비 (높이 54u 고정). 노선번호 기호는 배지 기본 64u로 고정."
}
note={
"KNOWN_DEVIATION — 고속도로 노선명.svg 높이는 48u이며 고속도로/특별시도 이름 표면 (#2E619A / #9729E5)은 선 색상과 다름"
+ "Canonical 후보: 모든 분류의 높이 54u; 두 표면 색상은 기록되었으나 미해결"
}
>
<div className="csi-controls">
<label>
콘텐츠
<select value={content} onChange={(e) => setContent(e.target.value)}>
{Object.entries(NAME_SAMPLES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</label>
<label>
노선명 재정의
<input value={routeName} onChange={(e) => setRouteName(e.target.value)} placeholder="(표본)" size={18} />
</label>
<label>
노선번호 재정의
<input value={routeNumber} onChange={(e) => setRouteNumber(e.target.value)} placeholder="(표본)" size={8} />
</label>
</div>
<div className="csi-swatchgrid csi-scale-50">
{classList.map((c) => (
<Row key={c.key} label={c.label} top>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{c.numberShape ? <RouteNumberBadge routeClass={c.key} number={numberFor(c)} /> : null}
<RouteNameBadge routeClass={c.key} name={nameFor(c)} />
</div>
</Row>
))}
</div>
</Panel>
<Panel
id="csi-area-direction" title="3. 방면 표기 — 방면 배지"
legend={
'노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다. '
+ "완전히 둥근 50u 캡슐, 흰색 80%의 안쪽 윤곽 2u, 7점 화살표 다각형, 콘텐츠 기반 너비."
}
>
<div className="csi-scale-50" style={{ position: 'relative', padding: '4px 0 18px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<DirectionBadge name={content === 'long' ? '평택제천' : '천안'} direction="start" />
<DirectionBadge name={content === 'long' ? '당진영덕' : '서울'} direction="end" />
</div>
<RouteLine routeClass="expressway" state="existing" />
</div>
</Panel>
<Panel
id="csi-area-administrative" title="4. 행정구역 표기 — 행정구역 레이블"
legend={
'기본형: 배경 정보와 함께 자연스럽게 표기하거나 여러 행정구역을 표기할 경우. '
+ "강조형: 행정구역을 명확히 구분하거나 주요 위치정보로 강조할 경우 — 글자마다 베벨 타일 하나."
}
>
<div className="csi-scale-40" style={{ display: 'flex', gap: 48, flexWrap: 'wrap' }}>
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 12 }}>기본형</div>
{Object.values(ADMIN_LEVELS).map((lv) => (
<div key={lv.key} style={{ marginBottom: 16 }}>
<AdministrativeLabel level={lv.key as AdminLevelKey} form="plain" name={content === 'long' ? '서울특별시' : undefined} />
</div>
))}
</div>
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 12 }}>강조형</div>
{Object.values(ADMIN_LEVELS).map((lv) => (
<div key={lv.key} style={{ marginBottom: 16 }}>
<AdministrativeLabel level={lv.key as AdminLevelKey} form="tiles" name={content === 'long' ? '서울특별시' : undefined} />
</div>
))}
</div>
</div>
</Panel>
<Panel
id="csi-area-north-arrow" title="5. 방위 표기 — 북쪽 화살표"
legend={"지도 및 실사화 화면에서 방위 정보가 필요할 때 선택적으로 사용한다. 기본형 = 채워진 나침반; 간소형 = 최소 링과 바늘."}
light
>
<div className="csi-scale-40" style={{ display: 'flex', gap: 60, alignItems: 'flex-start' }}>
{[0, 90, 37].map(angle => <NorthArrow key={`full-${angle}`} variant="full" orientation={angle} />)}
{[0, 90, 37].map(angle => <NorthArrow key={`simple-${angle}`} variant="simple" orientation={angle} />)}
</div>
</Panel>
<Panel
id="csi-area-zone-boundary" title="6. 구역 표현 — 영역 경계"
legend={"선 형태: 실선 / 긴 파선. ※ 선 두께는 영역 범위와 화면 배율에 따라 조정 가능 — 두께는 명시적으로 배율에 의존하므로 고정 Canonical 값이 아님."}
>
<div style={{ display: 'grid', gap: 26, padding: '8px 0' }}>
<Row label="실선"><BoundaryLine form="solid" /></Row>
<Row label="긴 파선"><BoundaryLine form="dashed" /></Row>
</div>
</Panel>
<Panel
id="csi-area-spatial-section" title="7. 사업 시 · 종점 표기 — 사업 범위 레이블 (동적 데이터)"
legend={
'사업 구간의 시점 · 종점과 전체 연장을 표기한다. 필요에 따라 측점(STA.)과 공구별 연장 정보를, 또 시 · 종점의 행정구역 정보를 함께 표기할 수 있다. '
+ '화면 구성에 따라 가로형 또는 세로형으로 배치한다.'
}
light
>
<div className="csi-scale-32 csi-spatial-specimens">
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 8 }}>기본형</div>
<div className="csi-specimen-frame"><ProjectExtent variant="basic" /></div>
</div>
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 8 }}>기본형 + 공구표기</div>
<div className="csi-specimen-frame"><ProjectExtent
variant="sections"
sections={[
{ name: '제 1공구', length: 'L=7.12km', weight: 7.12 },
{ name: '제 2공구', length: 'L=8.40km', weight: 8.40 },
{ name: '제 3공구', length: 'L=5.39km', weight: 5.39 },
{ name: '제 4공구', length: 'L=4.11km', weight: 4.11 },
]}
/></div>
</div>
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 8 }}>기본형 + 행정구역 전환 표기</div>
<div className="csi-specimen-frame"><ProjectExtent variant="regions" projectName="사업구간 (5공구)" totalLength="L=4.00km" /></div>
</div>
<div>
<div className="csi-row-label" style={{ width: 'auto', marginBottom: 8 }}>세로형</div>
<div className="csi-specimen-frame">
<ProjectExtent variant="vertical" projectName="사업구간 (5공구)" totalLength="L=4.00km" />
</div>
</div>
</div>
</Panel>
</div>
);
}
@@ -0,0 +1,307 @@
.center-title-reference {
display: grid;
gap: 16px;
}
.center-title-reference__sample {
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-stroke-subtle);
border-radius: 4px;
background: var(--color-bg-content);
}
.center-title-reference__sample > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-stroke-subtle);
}
.center-title-reference__sample > header div {
display: flex;
align-items: baseline;
gap: 10px;
}
.center-title-reference__sample > header strong {
color: var(--color-action-primary);
font-size: 14px;
line-height: 20px;
}
.center-title-reference__sample > header span,
.center-title-reference__sample > header small {
color: var(--color-fg-secondary);
font-size: 12px;
line-height: 18px;
}
.center-title-reference__stage {
min-width: 0;
min-height: 184px;
display: flex;
align-items: center;
padding: 32px;
overflow: auto;
background: #d6d6d6;
}
.center-title {
--center-title-border: 3.609px;
--center-title-main-height: 100px;
--center-title-sub-height: 67px;
--center-title-chamfer: 14.436px;
--center-title-main-start: 20.65px;
--center-title-main-gap: 8.44px;
--center-title-main-end: 42.466px;
--center-title-route-padding: 23.5px;
--center-title-sub-start: 12px;
--center-title-sub-end: 19.247px;
/* title.md §6.4.8 (Q1, 2026-09-28): Source ratio of the main outer inline-size. */
--center-title-main-bevel: 2.41747%;
flex: none;
display: inline-flex;
align-items: flex-start;
width: max-content;
margin: 0;
color: #f5f9ff;
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
}
.center-title[data-composition='projectNameRoute'] {
--center-title-main-end: 32.466px;
}
.center-title[data-composition='routeProjectName'] {
--center-title-main-end: 24.947px;
--center-title-route-padding: 24.294px;
}
.center-title[data-composition='routeProjectNameRoute'] {
--center-title-main-end: 32.572px;
--center-title-sub-end: 14.963px;
}
.center-title__body {
display: flex;
flex-direction: column;
align-items: flex-start;
width: max-content;
}
.center-title__main {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
width: max-content;
height: var(--center-title-main-height);
padding-inline: var(--center-title-main-end);
padding-inline-start: var(--center-title-main-start);
border: var(--center-title-border) solid #f5f9ff;
overflow: hidden;
isolation: isolate;
background: #f5f9ff;
--center-title-surface-frame-inline: calc(2 * var(--center-title-border));
--center-title-surface-position: center;
}
.center-title:has(.center-title__route-classification) .center-title__main {
border-inline-start: 0;
--center-title-surface-frame-inline: var(--center-title-border);
--center-title-surface-position: left;
}
/* Paint the frame and chamfer as one surface. Separate triangle clipping left
dark coverage seams at fractional inner-frame edges (title.md §6.4.3). */
.center-title__main::after {
position: absolute;
inset: 0;
z-index: -1;
content: '';
background: linear-gradient(90deg, #002f68 0%, #001a41 100%);
/* Keep the authored gradient extent at the outer frame, not the fill box. */
background-size: calc(100% + var(--center-title-surface-frame-inline)) 100%;
background-position: var(--center-title-surface-position);
clip-path: polygon(0 0, 100% 0,
100% calc(100% - var(--center-title-chamfer)),
calc(100% - var(--center-title-chamfer)) 100%, 0 100%);
}
/* Type 3/4 only: title.md §6.4.8. Alpha and compositing are per-Type (Type 3
0.25 additive, Type 4 0.20 source-over). The box spans the main outer
inline-size, so the percentage stop keeps the extent proportional to it at
every content length (title.md §6.6.2). */
.center-title:has(.center-title__route-classification) .center-title__main::before {
position: absolute;
inset: 0;
z-index: 0;
content: '';
width: calc(100% + var(--center-title-border));
background: linear-gradient(90deg, rgb(255 255 255 / 25%) 0,
rgb(255 255 255 / 0%) var(--center-title-main-bevel));
pointer-events: none;
}
.center-title[data-composition='routeProjectName'] .center-title__main::before {
/* Restore the previous Type 3 transition, independently of its divider. */
content: '';
mix-blend-mode: plus-lighter;
}
.center-title[data-composition='routeProjectName'] .center-title__main::after {
/* Keep fractional join pixels inside the fill clip, not on its edge. */
clip-path: polygon(-1px 0, 100% 0,
100% calc(100% - var(--center-title-chamfer)),
calc(100% - var(--center-title-chamfer)) 100%, -1px 100%);
}
.center-title[data-composition='routeProjectNameRoute'] .center-title__main::before {
background: linear-gradient(90deg, rgb(255 255 255 / 20%) 0,
rgb(255 255 255 / 0%) var(--center-title-main-bevel));
}
.center-title__main-text {
z-index: 1;
display: flex;
align-items: center;
gap: var(--center-title-main-gap);
white-space: nowrap;
font-size: 44.5px;
line-height: 1;
letter-spacing: -0.04326em;
text-shadow: 0 3.609px 3.609px rgb(0 0 0 / 45%);
transform: translateY(-2px);
}
.center-title__context {
color: #fff;
font-weight: 500;
}
.center-title__project-name {
color: #4cc3ff;
font-weight: 700;
}
.center-title__chamfer {
position: absolute;
right: 0;
bottom: 0;
width: var(--center-title-chamfer);
height: var(--center-title-chamfer);
/* Semantic geometry marker; the shared main paint owns the visible chamfer. */
pointer-events: none;
}
.center-title__route-classification {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: max-content;
height: var(--center-title-main-height);
padding-inline: var(--center-title-route-padding);
border: var(--center-title-border) solid #f5f9ff;
border-inline-end: 0;
white-space: nowrap;
background: linear-gradient(158deg, #1464d7 0%, #072d65 100%);
box-shadow: inset -7.218px 0 14.436px rgb(0 17 42 / 50%);
color: #fff;
font-size: 40px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.112em;
text-shadow: 0 2px 2px rgb(0 0 0 / 55%);
-webkit-text-stroke: 1.8px #001b42;
paint-order: stroke fill;
}
.center-title__route-classification::after {
position: absolute;
inset-block: 0;
right: 0;
width: 1.805px;
content: '';
background: rgb(0 0 0 / 20%);
}
.center-title[data-composition='routeProjectName'] .center-title__route-classification::after {
content: none;
}
.center-title[data-composition='routeProjectName'] .center-title__route-classification > span {
transform: translate(-3px, -3px) scaleY(0.9);
}
.center-title[data-composition='routeProjectNameRoute'] .center-title__route-classification {
flex-direction: column;
gap: 0;
height: 167px;
padding-inline: 23.32px;
background: linear-gradient(132deg, #0054cc 0%, #001b42 100%);
font-size: 38.5px;
line-height: 1.32;
letter-spacing: -0.106em;
}
.center-title[data-composition='routeProjectNameRoute'] .center-title__route-classification > span {
transform: translate(-1px, -2px) scale(0.984, 0.953);
transform-origin: left top;
}
.center-title__project-route {
box-sizing: border-box;
display: flex;
align-items: center;
width: max-content;
height: var(--center-title-sub-height);
padding-inline: var(--center-title-sub-end);
padding-inline-start: var(--center-title-sub-start);
padding-block-start: 0;
border-inline: var(--center-title-border) solid #fff;
border-bottom: var(--center-title-border) solid #fff;
background: linear-gradient(90deg, #f5f9ff 0%, #f5f9ff 6.05%, #dce7f6 100%);
box-shadow: 0 3.609px 3.609px 0 #97b6db inset;
color: #002555;
white-space: nowrap;
font-size: 34px;
font-weight: 500;
line-height: 1;
letter-spacing: -0.075em;
text-indent: -1.5px;
}
.center-title__project-route-value {
letter-spacing: inherit;
}
.center-title[data-composition='routeProjectNameRoute'] .center-title__project-route-value {
margin-inline-start: 4px;
}
.center-title[data-composition='projectNameRoute'] .center-title__project-route {
letter-spacing: -0.067em;
}
.center-title[data-composition='routeProjectNameRoute'] .center-title__project-route {
letter-spacing: -0.06em;
}
@media (max-width: 720px) {
.center-title-reference__sample > header {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.center-title-reference__stage {
padding: 24px;
}
}
@@ -0,0 +1,138 @@
import './CenterTitle.css';
const DEFAULT_CONTENT = Object.freeze({
context: '서산영덕선',
projectName: '대산~당진 건설공사',
routeClassification: '고속국도 제30호',
projectRoute: 'L=1.0km',
});
export const CENTER_TITLE_COMPOSITIONS = Object.freeze({
projectName: Object.freeze({
type: 'Type 1',
label: '사업명',
sourceFile: '사업명.svg',
sourceViewport: Object.freeze({ width: 620, height: 100 }),
hasRouteClassification: false,
hasProjectRoute: false,
}),
projectNameRoute: Object.freeze({
type: 'Type 2',
label: '사업명 + 사업노선',
sourceFile: '사업명+사업노선.svg',
sourceViewport: Object.freeze({ width: 610, height: 167 }),
hasRouteClassification: false,
hasProjectRoute: true,
}),
routeProjectName: Object.freeze({
type: 'Type 3',
label: '노선구분 + 사업명',
sourceFile: '노선구분 + 사업명.svg',
sourceViewport: Object.freeze({ width: 890, height: 100 }),
hasRouteClassification: true,
hasProjectRoute: false,
}),
routeProjectNameRoute: Object.freeze({
type: 'Type 4',
label: '노선구분 + 사업명 + 사업노선',
sourceFile: '노선구분 + 사업명 + 사업노선.svg',
sourceViewport: Object.freeze({ width: 782, height: 167 }),
hasRouteClassification: true,
hasProjectRoute: true,
}),
});
export type CenterTitleComposition = keyof typeof CENTER_TITLE_COMPOSITIONS;
function splitRouteClassification(value: string): string[] {
const match = value.trim().match(/^(.*?)(\s+제\s*\d+호)$/u);
return match ? [match[1], match[2].trim()] : [value];
}
export function CenterTitle({
composition = 'projectName',
context = DEFAULT_CONTENT.context,
projectName = DEFAULT_CONTENT.projectName,
routeClassification = DEFAULT_CONTENT.routeClassification,
projectRoute,
className = '',
}: {
composition?: CenterTitleComposition;
context?: string;
projectName?: string;
routeClassification?: string;
projectRoute?: string;
className?: string;
}) {
const specimen = CENTER_TITLE_COMPOSITIONS[composition] ?? CENTER_TITLE_COMPOSITIONS.projectName;
const routeValue = projectRoute ?? (
composition === 'routeProjectNameRoute' ? 'L=25.36km' : DEFAULT_CONTENT.projectRoute
);
const routeLines = composition === 'routeProjectNameRoute'
? splitRouteClassification(routeClassification)
: [routeClassification];
const projectRouteSeparator = composition === 'routeProjectNameRoute' ? ' ' : '';
const accessibleName = [
specimen.hasRouteClassification ? `노선구분: ${routeClassification}` : null,
`사업명: ${context} ${projectName}`,
specimen.hasProjectRoute ? `사업노선: ${routeValue}` : null,
].filter(Boolean).join('. ');
return (
<figure
className={['center-title', className].filter(Boolean).join(' ')}
role="group"
aria-label={accessibleName}
data-center-component="title"
data-composition={composition}
data-source-evidence={specimen.sourceFile}
data-source-viewport={`${specimen.sourceViewport.width}x${specimen.sourceViewport.height}`}
>
{specimen.hasRouteClassification && (
<div className="center-title__route-classification" aria-hidden="true">
{routeLines.map((line, index) => <span key={`${index}-${line}`}>{line}</span>)}
</div>
)}
<div className="center-title__body">
<div className="center-title__main" aria-hidden="true">
<div className="center-title__main-text">
<span className="center-title__context">{context}</span>
<span className="center-title__project-name">{projectName}</span>
</div>
<span className="center-title__chamfer" aria-hidden="true" />
</div>
{specimen.hasProjectRoute && (
<div className="center-title__project-route" aria-hidden="true">
사업노선{projectRouteSeparator}<span className="center-title__project-route-value">({routeValue})</span>
</div>
)}
</div>
</figure>
);
}
export function CenterTitleReferenceDemo() {
return (
<div className="center-title-reference" data-reference-component="center-title">
{Object.entries(CENTER_TITLE_COMPOSITIONS).map(([composition, specimen]) => (
<article className="center-title-reference__sample" key={composition}>
<header>
<div><strong>{specimen.type}</strong><span>{specimen.label}</span></div>
<small>{specimen.sourceViewport.width} × {specimen.sourceViewport.height} · Source 근거 뷰포트</small>
</header>
<div className="center-title-reference__stage">
<CenterTitle composition={composition as CenterTitleComposition} />
</div>
</article>
))}
<article className="center-title-reference__sample" data-content-test="long">
<header><strong>유형 3 · 긴 콘텐츠</strong><span>같은 연결과 고정 외곽 지오메트리; 콘텐츠 기반 너비</span></header>
<div className="center-title-reference__stage">
<CenterTitle composition="routeProjectName" projectName="서해안고속도로 대산연결 제2구간 장기 건설사업 기본 및 실시설계" />
</div>
</article>
</div>
);
}
@@ -0,0 +1,92 @@
.compact-facility-connection {
/* Nominal Web specimen size and stem length are preview choices, not Canonical geometry. */
--compact-route-primary: 18px;
--compact-route-outline: calc(var(--compact-route-primary) * .2);
--compact-stem-width: calc(var(--compact-route-primary) * .1);
--compact-joint-size: calc(var(--compact-route-primary) * .4);
--compact-stem-length: 18px;
width: min(100%, 680px);
min-width: 360px;
position: relative;
display: grid;
grid-template-rows: auto var(--compact-stem-length) auto;
justify-items: center;
padding-block: 14px 17px;
isolation: isolate;
}
.compact-facility-connection__route {
z-index: 3;
grid-row: 1;
position: relative;
width: 84%;
height: var(--compact-route-primary);
box-sizing: content-box;
background: #d21203;
border: var(--compact-route-outline) solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
}
.compact-facility-connection__segment {
position: absolute;
inset-block: 0;
left: 50%;
width: 12px;
transform: translateX(-50%);
background: #ff7700;
border-inline: 2px solid #fff;
}
.compact-facility-connection__anchor {
z-index: 1;
position: absolute;
top: 50%;
left: 50%;
width: var(--compact-route-primary);
height: var(--compact-route-primary);
box-sizing: content-box;
transform: translate(-50%, -50%);
background: #d21203;
border: calc(var(--compact-route-primary) * .12) solid #fff;
border-radius: 50%;
}
.compact-facility-connection__stem {
z-index: 4;
grid-row: 2;
position: relative;
width: var(--compact-stem-width);
height: 100%;
background: #400500;
}
.compact-facility-connection__stem::before {
/* Paint across the lower white outline, up to the orange/primary lower edge. */
content: '';
position: absolute;
left: 0;
bottom: 100%;
width: 100%;
height: var(--compact-route-outline);
background: inherit;
}
.compact-facility-connection__stem > span {
position: absolute;
bottom: calc(var(--compact-joint-size) / -2);
left: 50%;
width: var(--compact-joint-size);
height: var(--compact-joint-size);
box-sizing: border-box;
transform: translateX(-50%);
background: #fff;
border: 0;
/* Keep the white nominal circle intact; paint its fine boundary outside. */
box-shadow: 0 0 0 1px #400500;
border-radius: 50%;
}
.compact-facility-connection > :last-child {
z-index: 1;
grid-row: 3;
}
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import './CompactFacilityConnection.css';
// Shared Web preview relationship; entity assets and text layout remain child-owned.
export function CompactFacilityConnection({ children, className, routeMode, sourceStatus, label }: {
children?: ReactNode;
className?: string;
routeMode?: string;
sourceStatus?: string;
label?: string;
}) {
const inline = routeMode === 'inline';
return (
<div className={`compact-facility-connection ${className}`} data-source-status={sourceStatus} data-connection-model="tunnel-shared" data-route-attachment={inline ? 'segment' : 'marker'} aria-label={label}>
<div className="compact-facility-connection__route facility-applied__route tunnel-applied-example__route" aria-hidden="true">
{inline
? <span className="compact-facility-connection__segment facility-applied__route-segment tunnel-applied-example__segment" />
: <span className="compact-facility-connection__anchor facility-applied__route-anchor" />}
</div>
<div className="compact-facility-connection__stem facility-applied__connector tunnel-applied-example__connector" aria-hidden="true"><span /></div>
{children}
</div>
);
}
@@ -0,0 +1,397 @@
.facility-reference,
.linked-facility {
width: 100%;
display: grid;
gap: 18px;
}
.facility-reference__variant-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 14px;
}
.facility-reference__sample,
.linked-facility__group,
.linked-facility__context,
.linked-facility__pending {
min-width: 0;
overflow: hidden;
background: rgba(217, 217, 217, .48);
border: 1px solid rgba(64, 5, 0, .14);
border-radius: 8px;
}
.facility-reference__sample > header,
.linked-facility__group > header,
.linked-facility__card > header {
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 14px;
background: rgba(255, 255, 255, .62);
border-bottom: 1px solid rgba(64, 5, 0, .12);
}
.facility-reference__sample > header strong,
.linked-facility__group > header h3,
.linked-facility__card > header strong {
margin: 0;
color: #400500;
font-size: 14px;
line-height: 20px;
}
.facility-reference__sample > header span,
.linked-facility__group > header span,
.linked-facility__card > header span {
color: #6e6759;
font-size: 12px;
line-height: 16px;
text-align: right;
}
.facility-reference__stage {
min-height: 154px;
display: grid;
place-items: center;
padding: 24px;
overflow-x: auto;
}
.facility-reference__row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 28px;
}
.local-facility {
position: relative;
width: max-content;
height: var(--facility-height);
display: block;
flex: 0 0 auto;
margin: 0;
overflow: hidden;
isolation: isolate;
}
.local-facility__parts {
height: var(--facility-height);
display: flex;
align-items: start;
}
.local-facility__part {
position: relative;
display: block;
flex: 0 0 auto;
overflow: hidden;
}
.local-facility__part.is-fixed { z-index: 1; }
.local-facility__part.is-flex { overflow: visible; }
.local-facility__part.is-two-part-name { overflow: hidden; }
.local-facility__part img {
position: absolute;
top: 0;
left: 50%;
max-width: none;
display: block;
transform: translateX(-50%) scaleX(var(--facility-part-scale));
transform-origin: center;
user-select: none;
}
/* Non-separated only: fixed parts paint on top (z-index: 1), so a fractional-scaleX
compositing seam at the shared edge is hidden by extending this layer 1px under each
neighbor rather than by resizing the flex part itself. */
.local-facility:not(.is-separated) .local-facility__part.is-flex img {
transform: translateX(-50%) scaleX(calc((var(--facility-part-rendered-width) + 2px) / var(--facility-part-base-width)));
}
.two-part-name-region {
position: relative;
display: block;
overflow: hidden;
background-color: var(--name-plate-surface);
background-image:
linear-gradient(var(--name-plate-tint-overlay), var(--name-plate-tint-overlay)),
linear-gradient(to right, rgba(0, 0, 0, .3), rgba(0, 0, 0, 0) var(--name-plate-seam-width));
background-repeat: no-repeat;
border-radius: 0 0 var(--name-plate-radius) 0;
box-shadow:
inset 0 var(--name-plate-border-width) var(--name-plate-border),
inset 0 calc(-1 * var(--name-plate-border-width)) var(--name-plate-border),
inset calc(-1 * var(--name-plate-border-width)) 0 var(--name-plate-border);
}
.two-part-name-region::after {
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow:
inset 0 var(--name-plate-border-width) var(--name-plate-tint-overlay),
inset 0 calc(-1 * var(--name-plate-border-width)) var(--name-plate-tint-overlay),
inset calc(-1 * var(--name-plate-border-width)) 0 var(--name-plate-tint-overlay);
content: '';
pointer-events: none;
}
.local-facility__part .local-facility__dimension-loader {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
transform: none;
}
.local-facility.is-separated { overflow: visible; }
.local-facility__labels {
z-index: 2;
position: absolute;
top: 0;
right: 0;
height: var(--facility-height);
display: block;
color: #fff;
pointer-events: none;
}
.local-facility__primary,
.local-facility__secondary {
position: absolute;
left: 50%;
display: block;
width: max-content;
transform: translateX(-50%);
color: #fff;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 700;
text-align: center;
white-space: nowrap;
}
.local-facility__primary {
top: 14px;
font-size: 30px;
line-height: 36px;
letter-spacing: -1.2px;
}
.local-facility__secondary {
top: 66px;
font-size: 16px;
line-height: 19px;
letter-spacing: -.64px;
}
/* Local silhouette Actual/Detail inherits common primary (14px); only secondary is overridden. */
.local-facility--silhouette.local-facility--actualDetail .local-facility__secondary { top: 70px; }
/* Bridge and Linked Bridge only: text sits lower than the common start. Keyed on
data-facility because the silhouette kind is shared with Tunnel.
Canonical owner: facility.md §12.21.1 — Bridge-specific CANONICAL_OVERRIDE,
OWNER_CONFIRMED 2026-09-19. Common primary stays 14px. */
.local-facility[data-facility='bridge'] .local-facility__primary,
.local-facility[data-facility='linked-bridge'] .local-facility__primary { top: 20px; }
/* Linked Tunnel uses this renderer, but its geometry owner is Tunnel common. */
.local-facility[data-facility='linked-tunnel'] .local-facility__primary { top: 22px; }
.local-facility[data-facility='linked-tunnel'] .local-facility__secondary { top: 68px; }
/* Facility-local user-confirmed 16px, not a Foundation spacing token. */
.local-facility[data-composition='two-part'] .local-facility__labels .local-facility__primary {
left: 16px;
transform: none;
text-align: left;
}
/* Badge Actual has a common content start, independent of label length.
Its fixed graphic boundary is the origin; Compact's 16px does not apply. */
.local-facility--badge.local-facility--actualDetail .local-facility__labels .local-facility__primary {
left: 0;
transform: none;
text-align: left;
}
/* Badge Actual only (Intersection / JC / IC): user-confirmed 3px horizontal label
correction (facility.md §12.23). Applied to the labels box so primary/sub keep their
shared start and bounds relationship. Not generalized to Tunnel / Bridge / Roadside,
whose §12.18 middle-center contract is unchanged. */
.local-facility--badge.local-facility--actualDetail .local-facility__labels { transform: translateX(3px); }
.local-facility__measure {
position: fixed;
width: 0;
height: 0;
overflow: visible;
visibility: hidden;
pointer-events: none;
}
.local-facility__measure .local-facility__primary,
.local-facility__measure .local-facility__secondary {
position: fixed;
left: auto;
top: auto;
transform: none;
}
.facility-reference__width-tests {
width: 100%;
display: grid;
gap: 16px;
}
.facility-reference__width-tests > div {
min-width: max-content;
display: grid;
grid-template-columns: 64px max-content;
align-items: center;
gap: 16px;
}
.facility-reference__width-tests > div > span {
color: #6e6759;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
}
.roadside-tag-tests {
width: 100%;
display: grid;
gap: 18px;
}
.roadside-tag-test {
min-width: max-content;
display: grid;
grid-template-columns: 132px max-content;
align-items: center;
gap: 18px;
}
.roadside-tag-test__meta {
display: grid;
gap: 2px;
color: #400500;
}
.roadside-tag-test__meta strong {
font-size: 13px;
line-height: 18px;
}
.roadside-tag-test__meta small {
color: #6e6759;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
}
.roadside-facility-tag {
--roadside-height: 66px;
--roadside-border-width: 4px;
--roadside-border-width: round(nearest, calc(var(--roadside-height) * .06), 1px);
--roadside-radius: calc(var(--roadside-height) * .12);
width: max-content;
height: var(--roadside-height);
box-sizing: border-box;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding-inline: 16px;
overflow: visible;
background: #6a0800;
border: var(--roadside-border-width) solid #400500;
border-radius: var(--roadside-radius);
color: #fff;
font-family: 'Noto Sans KR', sans-serif;
font-size: 30px;
font-weight: 700;
line-height: 36px;
letter-spacing: -1.2px;
text-align: center;
white-space: nowrap;
}
.roadside-facility-tag > span {
display: block;
width: max-content;
white-space: nowrap;
}
.linked-facility__context {
min-height: 76px;
display: grid;
grid-template-columns: max-content minmax(120px, 1fr) max-content;
align-items: center;
gap: 16px;
padding: 16px 20px;
color: #400500;
}
.linked-facility__context i {
height: 18px;
background: #6f797e;
border: 3px solid #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
.linked-facility__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
padding: 14px;
}
.linked-facility__card {
min-width: 0;
background: rgba(255,255,255,.5);
border: 1px solid rgba(64,5,0,.12);
border-radius: 6px;
overflow: hidden;
}
.linked-facility__variant {
min-height: 122px;
display: grid;
grid-template-columns: 56px minmax(180px, 1fr);
grid-template-rows: 1fr 1fr;
align-items: center;
justify-items: center;
gap: 4px 10px;
padding: 10px;
overflow-x: auto;
}
.linked-facility__variant + .linked-facility__variant { border-top: 1px solid rgba(64,5,0,.1); }
.linked-facility__variant small { grid-row: 1 / 3; color: #6e6759; font-size: 10px; font-weight: 700; letter-spacing: .08em; }
.linked-facility__pending {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
color: #400500;
}
.linked-facility__pending span { color: #6e6759; }
@media (max-width: 760px) {
.facility-reference__variant-grid { grid-template-columns: 1fr; }
.facility-reference__stage { justify-content: start; }
.linked-facility__context { grid-template-columns: 1fr; }
.linked-facility__pending { align-items: flex-start; flex-direction: column; }
}
@@ -0,0 +1,595 @@
import { useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import './FacilityBatch.css';
import { CompactFacilityConnection } from './CompactFacilityConnection';
export type FacilityVariant = 'positionMap' | 'actualDetail';
export type FacilityKind = 'silhouette' | 'badge' | 'roadside-actual';
export type FacilityNamePlate = { baseWidth: number; surface: string; border: string; tint?: string; tintOpacity?: number };
export type FacilityPiece = { url: string; relativePath: string; part: string };
type SizedPiece = FacilityPiece & { role: 'flex' | 'fixed'; width: number; height: number };
export type FacilityAssetSet = { sourcePath: string; pieces: readonly (FacilityPiece & { role: 'flex' | 'fixed' })[] };
export type FacilityConfig = {
id: string;
label: string;
sourcePrefix?: string;
defaultName: string;
secondaryText: string;
kind: FacilityKind;
flexPadding: number;
gaps: Partial<Record<FacilityVariant, number[]>>;
sourceReferenceTotals?: Partial<Record<FacilityVariant, number>>;
routeMode: string;
labelRegion: string;
namePlate?: FacilityNamePlate;
variants: Partial<Record<FacilityVariant, FacilityAssetSet | null>>;
};
const assetUrls = import.meta.glob<string>([
'../assets/facility/batch/source/**/*.svg',
], {
eager: true,
query: '?url',
import: 'default',
});
const ASSET_MARKER = '/batch/source/';
const PART_ORDER: Record<string, number> = { '왼쪽': 0, '중앙': 1, '오른쪽': 2, '위치도': 1 };
const NAME_PLATE_GEOMETRY = Object.freeze({ seamRatio: 0.1, rightTerminalRadius: 30, borderRatio: 0.06 });
const DIRECT_NAME_PLATE_BORDER = '#400500';
const LINKED_NAME_PLATE_BORDER = '#555555';
const LINKED_NAME_PLATE_TINTS: Readonly<Record<string, string>> = Object.freeze({
'고속도로, 국도': '#0D6199',
'지방도': '#A77900',
'특별시도': '#713699',
'시도,군도': '#5B8736',
});
export const FACILITY_BATCH_ASSETS = Object.freeze(Object.fromEntries(
Object.entries(assetUrls).map(([modulePath, url]) => {
const relativePath = modulePath.split(ASSET_MARKER)[1];
const filename = relativePath.split('/').at(-1)!.replace('.svg', '');
return [relativePath, Object.freeze({
url,
relativePath,
part: filename,
})];
}),
));
function getAssetSet(prefix: string, variant: string, optional: true): FacilityAssetSet | null;
function getAssetSet(prefix: string, variant: string, optional?: false): FacilityAssetSet;
function getAssetSet(prefix: string, variant: string, optional = false): FacilityAssetSet | null {
const start = `${prefix}/${variant}/`;
const pieces = Object.values(FACILITY_BATCH_ASSETS)
.filter((asset) => asset.relativePath.startsWith(start))
.sort((a, b) => PART_ORDER[a.part] - PART_ORDER[b.part]);
if (!pieces.length && optional) return null;
if (!pieces.length) throw new Error(`Missing local Facility assets: ${start}`);
return Object.freeze({
sourcePath: `시설물 표현/${start.slice(0, -1)}`,
pieces: Object.freeze(pieces.map((piece, index) => Object.freeze({
...piece,
role: pieces.length === 1
? 'flex'
: piece.part === '중앙' || (pieces.length === 2 && index === 1)
? 'flex'
: 'fixed',
}))),
});
}
function makeConfig({ id, label, sourcePrefix, defaultName, secondaryText = '', kind, flexPadding, gaps, sourceReferenceTotals, routeMode = 'anchored', labelRegion = 'all', namePlate }: {
id: string; label: string; sourcePrefix: string; defaultName: string; secondaryText?: string; kind: FacilityKind; flexPadding: number;
gaps: FacilityConfig['gaps']; sourceReferenceTotals: FacilityConfig['sourceReferenceTotals']; routeMode?: string; labelRegion?: string; namePlate?: FacilityNamePlate;
}): FacilityConfig {
return Object.freeze({
id,
label,
sourcePrefix,
defaultName,
secondaryText,
kind,
flexPadding,
gaps,
sourceReferenceTotals,
routeMode,
labelRegion,
namePlate,
variants: Object.freeze({
positionMap: getAssetSet(sourcePrefix, '위치도'),
actualDetail: getAssetSet(sourcePrefix, '실사화'),
}),
});
}
export const FACILITY_CONFIGS = Object.freeze({
bridge: makeConfig({
id: 'bridge', label: "교량", sourcePrefix: '교량', defaultName: '운산1교', secondaryText: 'L=200m',
kind: 'silhouette', flexPadding: 10, gaps: { positionMap: [13, 13], actualDetail: [10, 13] },
sourceReferenceTotals: { positionMap: 197, actualDetail: 197 }, routeMode: 'inline',
}),
intersection: makeConfig({
id: 'intersection', label: "교차로", sourcePrefix: '교차로', defaultName: '신시교차로',
kind: 'badge', flexPadding: 40, gaps: { positionMap: [10], actualDetail: [13, 11] },
sourceReferenceTotals: { positionMap: 245, actualDetail: 255 }, labelRegion: 'afterLeft',
namePlate: { baseWidth: 179, surface: '#420E0A', border: DIRECT_NAME_PLATE_BORDER },
}),
junction: makeConfig({
id: 'junction', label: "분기점 / JC", sourcePrefix: '분기점, 나들목/분기점', defaultName: '당진분기점',
kind: 'badge', flexPadding: 40, gaps: { positionMap: [10], actualDetail: [20, 25] },
sourceReferenceTotals: { positionMap: 245, actualDetail: 255 }, labelRegion: 'afterLeft',
namePlate: { baseWidth: 179, surface: '#6A0800', border: DIRECT_NAME_PLATE_BORDER },
}),
interchange: makeConfig({
id: 'interchange', label: "나들목 / IC", sourcePrefix: '분기점, 나들목/나들목', defaultName: '정미나들목',
kind: 'badge', flexPadding: 40, gaps: { positionMap: [10], actualDetail: [21, 26] },
sourceReferenceTotals: { positionMap: 245, actualDetail: 255 }, labelRegion: 'afterLeft',
namePlate: { baseWidth: 179, surface: '#6A0800', border: DIRECT_NAME_PLATE_BORDER },
}),
});
function classNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
}
function calculateFlexibleWidth(config: FacilityConfig, pieces: SizedPiece[], textWidth: number, variant: FacilityVariant, name: string) {
const flexIndex = pieces.findIndex((piece) => piece.role === 'flex');
const flexPiece = pieces[flexIndex]!;
const fixedWidth = pieces.filter((piece) => piece.role === 'fixed').reduce((sum, piece) => sum + piece.width, 0);
const sourceReferenceTotal = config.sourceReferenceTotals?.[variant];
if (pieces.length === 3 && variant === 'actualDetail' && config.kind === 'badge') {
// The existing end allowance includes the fixed right cap. Only the right
// edge follows content; the fixed left graphic and text origin stay locked.
return Math.ceil(textWidth + config.flexPadding) - pieces.at(-1)!.width;
}
if (pieces.length !== 2 && name === config.defaultName && Number.isFinite(sourceReferenceTotal)) {
return Math.max(flexPiece.width, sourceReferenceTotal! - fixedWidth);
}
if (pieces.length === 1) return Math.max(flexPiece.width, Math.ceil(textWidth + config.flexPadding));
if (pieces.length === 2) return Math.ceil(textWidth + config.flexPadding);
if (pieces.length === 3 && variant === 'actualDetail') {
// Labels occupy the center only; fixed graphic widths never subsidize content.
return Math.max(flexPiece.width, Math.ceil(textWidth + config.flexPadding));
}
if (config.kind === 'silhouette' || config.labelRegion === 'middle') {
return Math.max(flexPiece.width, Math.ceil(textWidth + config.flexPadding));
}
return Math.max(flexPiece.width, Math.ceil(textWidth + config.flexPadding - fixedWidth));
}
function TwoPartNameRegion({ renderedWidth, height, namePlate }: { renderedWidth: number; height: number; namePlate: FacilityNamePlate }) {
const seamWidth = Math.round(namePlate.baseWidth * NAME_PLATE_GEOMETRY.seamRatio);
const terminalWidth = NAME_PLATE_GEOMETRY.rightTerminalRadius;
const renderedBodyWidth = Math.max(0, renderedWidth - seamWidth - terminalWidth);
const tintOpacity = namePlate.tint ? (namePlate.tintOpacity ?? 0.1) : 0;
const tintOverlay = namePlate.tint
? `rgb(${Number.parseInt(namePlate.tint.slice(1, 3), 16)} ${Number.parseInt(namePlate.tint.slice(3, 5), 16)} ${Number.parseInt(namePlate.tint.slice(5, 7), 16)} / ${tintOpacity})`
: 'transparent';
return (
<span
className="two-part-name-region"
data-source-width={namePlate.baseWidth}
data-rendered-width={renderedWidth}
data-name-body-width={renderedBodyWidth}
data-right-terminal-width={terminalWidth}
data-graphic-implementation="reconstructed-name-plate"
data-source-surface={namePlate.surface}
data-source-border={namePlate.border}
data-source-tint={namePlate.tint ?? 'none'}
data-source-tint-opacity={tintOpacity}
style={{
width: `${renderedWidth}px`,
height: `${height}px`,
'--name-plate-surface': namePlate.surface,
'--name-plate-border': namePlate.border,
'--name-plate-tint-overlay': tintOverlay,
'--name-plate-border-width': `${height * NAME_PLATE_GEOMETRY.borderRatio}px`,
'--name-plate-radius': `${terminalWidth}px`,
'--name-plate-seam-width': `${seamWidth}px`,
} as CSSProperties}
/>
);
}
export function LocalAssetFacility({
config,
name = config.defaultName,
secondaryText = config.secondaryText,
variant = 'positionMap',
separated = false,
showLabels = !separated,
className = '',
}: {
config: FacilityConfig;
name?: string;
secondaryText?: string;
variant?: FacilityVariant;
separated?: boolean;
showLabels?: boolean;
className?: string;
}) {
const assetSet = config.variants[variant]!;
const primaryProbeRef = useRef<HTMLSpanElement>(null);
const secondaryProbeRef = useRef<HTMLSpanElement>(null);
const fallbackHeight = config.kind === 'silhouette' ? (variant === 'positionMap' ? 93 : 95) : 66;
const [assetDimensions, setAssetDimensions] = useState<Record<string, { width: number; height: number }>>({});
const sizedPieces: SizedPiece[] = assetSet.pieces.map((piece, index) => ({
...piece,
width: assetDimensions[piece.relativePath]?.width
?? (assetSet.pieces.length === 2 && index === 1 && config.namePlate ? config.namePlate.baseWidth : 1),
height: assetDimensions[piece.relativePath]?.height ?? fallbackHeight,
}));
const baseFlexWidth = sizedPieces.find((piece) => piece.role === 'flex')!.width;
const [flexWidth, setFlexWidth] = useState(baseFlexWidth);
useLayoutEffect(() => {
if (separated) {
setFlexWidth(baseFlexWidth);
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 nextWidth = calculateFlexibleWidth(config, sizedPieces, Math.max(primaryWidth, secondaryWidth), variant, name);
setFlexWidth((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(); };
}, [assetDimensions, assetSet, baseFlexWidth, config, name, secondaryText, separated]);
const height = Math.max(...sizedPieces.map((piece) => piece.height));
const gaps = separated ? (config.gaps[variant] ?? []) : [];
const renderedWidths = sizedPieces.map((piece) => piece.role === 'flex' ? flexWidth : piece.width);
const isTwoPart = sizedPieces.length === 2;
const isThreePartActual = sizedPieces.length === 3 && variant === 'actualDetail';
const labelRegion = isTwoPart ? 'afterLeft' : isThreePartActual ? 'middle' : config.labelRegion;
const isContentDrivenActual = isThreePartActual && config.kind === 'badge';
const sourceReferenceTotal = !isTwoPart && !isContentDrivenActual && name === config.defaultName ? config.sourceReferenceTotals?.[variant] : undefined;
const leftInset = ['afterLeft', 'middle'].includes(labelRegion) && sizedPieces.length > 1 ? renderedWidths[0] : 0;
const rightInset = labelRegion === 'middle' ? renderedWidths.at(-1)! : 0;
const accessibleName = [config.label, name, secondaryText, variant === 'positionMap' ? '위치도' : '실사화', separated ? '분리 표현' : null]
.filter(Boolean).join(', ');
return (
<figure
className={classNames('local-facility', `local-facility--${config.kind}`, `local-facility--${variant}`, separated && 'is-separated', className)}
role="img"
aria-label={accessibleName}
data-facility={config.id}
data-label-region={labelRegion}
data-composition={isTwoPart ? 'two-part' : sizedPieces.length === 3 ? 'three-part' : 'one-part'}
data-variant={variant}
data-separated={separated ? 'true' : 'false'}
data-flex-width={flexWidth}
data-height={height}
data-source-reference-total={sourceReferenceTotal}
style={{ '--facility-height': `${height}px` } as CSSProperties}
>
<span className="local-facility__parts" aria-hidden="true">
{sizedPieces.map((piece, index) => {
const width = renderedWidths[index];
const isTwoPartNameRegion = sizedPieces.length === 2 && index === 1 && config.namePlate;
const updateDimensions = (widthValue: number, heightValue: number) => {
setAssetDimensions((current) => current[piece.relativePath]?.width === widthValue
&& current[piece.relativePath]?.height === heightValue
? current
: { ...current, [piece.relativePath]: { width: widthValue, height: heightValue } });
};
return (
<span
key={piece.relativePath}
className={classNames('local-facility__part', `is-${piece.role}`, isTwoPartNameRegion && 'is-two-part-name')}
data-part={piece.part}
data-base-width={piece.width}
data-rendered-width={width}
style={{ width: `${width}px`, height: `${piece.height}px`, marginLeft: `${gaps[index - 1] ?? 0}px` }}
>
{isTwoPartNameRegion ? (
<TwoPartNameRegion renderedWidth={width} height={piece.height} namePlate={config.namePlate!} />
) : (
<img
src={piece.url}
alt=""
draggable="false"
onLoad={(event) => updateDimensions(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight)}
style={{
width: `${piece.width}px`,
height: `${piece.height}px`,
'--facility-part-scale': width / piece.width,
'--facility-part-base-width': `${piece.width}px`,
'--facility-part-rendered-width': `${width}px`,
} as CSSProperties}
/>
)}
</span>
);
})}
</span>
{showLabels ? (
<span className="local-facility__labels" style={{ left: `${leftInset}px`, right: `${rightInset}px` }} aria-hidden="true">
<span className="local-facility__primary">{name}</span>
{secondaryText ? <span className="local-facility__secondary">{secondaryText}</span> : null}
</span>
) : null}
<span className="local-facility__measure" aria-hidden="true">
<span ref={primaryProbeRef} className="local-facility__primary">{name}</span>
<span ref={secondaryProbeRef} className="local-facility__secondary">{secondaryText}</span>
</span>
</figure>
);
}
function Sample({ title, note, children }: { title: string; note?: string; children?: ReactNode }) {
return (
<article className="facility-reference__sample">
<header><strong>{title}</strong>{note ? <span>{note}</span> : null}</header>
<div className="facility-reference__stage">{children}</div>
</article>
);
}
function CompactAppliedExample({ config }: { config: FacilityConfig }) {
return (
<CompactFacilityConnection className={classNames('facility-applied', `facility-applied--${config.routeMode}`)} routeMode={config.routeMode} sourceStatus={config.id === 'interchange' ? 'SOURCE_PENDING' : 'SOURCE_RECEIVED'} label={`${config.label} 간략 적용 예제`}>
<LocalAssetFacility config={config} />
</CompactFacilityConnection>
);
}
export type RoadsideEntity = 'rest-area' | 'drowsy-shelter';
const ROADSIDE_DEFAULT_LABELS: Readonly<Record<RoadsideEntity, string>> = Object.freeze({
'rest-area': '휴게소',
'drowsy-shelter': '졸음쉼터',
});
const ROADSIDE_ACTUAL_CONFIG: FacilityConfig = Object.freeze({
id: 'roadside',
label: "도로변 시설물",
defaultName: ROADSIDE_DEFAULT_LABELS['drowsy-shelter'],
secondaryText: '',
kind: 'roadside-actual',
flexPadding: 0,
gaps: { actualDetail: [19, 14] },
routeMode: 'anchored',
labelRegion: 'middle',
variants: Object.freeze({ actualDetail: getAssetSet('기타 시설물', '실사화') }),
});
export function RoadsideFacilityTag({ entity = 'drowsy-shelter', label, variant = 'positionMap', separated = false, className = '' }: {
entity?: RoadsideEntity;
label?: string;
variant?: FacilityVariant;
separated?: boolean;
className?: string;
}) {
const resolvedLabel = label ?? ROADSIDE_DEFAULT_LABELS[entity] ?? ROADSIDE_DEFAULT_LABELS['drowsy-shelter'];
const entityLabel = entity === 'rest-area' ? "휴게소" : "졸음쉼터";
if (variant === 'actualDetail') {
return (
<LocalAssetFacility
config={ROADSIDE_ACTUAL_CONFIG}
name={resolvedLabel}
variant="actualDetail"
separated={separated}
className={classNames('roadside-facility-tag--actual', className)}
/>
);
}
return (
<span
className={classNames('roadside-facility-tag', className)}
role="img"
aria-label={`${entityLabel}, ${resolvedLabel}`}
data-roadside-entity={entity}
data-variant="positionMap"
>
<span>{resolvedLabel}</span>
</span>
);
}
function RoadsideAppliedExample() {
return (
<CompactFacilityConnection className="facility-applied facility-applied--anchored roadside-applied" routeMode="anchored" sourceStatus="SOURCE_PENDING" label="졸음쉼터 연계 노선 간략 적용 예제">
<RoadsideFacilityTag entity="drowsy-shelter" />
</CompactFacilityConnection>
);
}
function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConfig; alternateName?: string }) {
const widthNames = useMemo(() => [
config.id === 'bridge' ? '1교' : '신시',
config.defaultName,
config.id === 'bridge' ? '서해안고속도로 대산연결 제2교량' : `서해안고속도로 ${config.defaultName}`,
], [config]);
return (
<div className="facility-reference" data-reference-facility={config.id}>
<div className="facility-reference__variant-grid">
<Sample title="Position Map" note="로컬 SVG · 위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="로컬 SVG · 실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
</div>
{alternateName ? (
<Sample title="의미 개체" note="같은 자산 · 이름으로 개체 구분">
<div className="facility-reference__row">
<LocalAssetFacility config={config} name={config.defaultName} />
<LocalAssetFacility config={config} name={alternateName} variant="actualDetail" />
</div>
</Sample>
) : null}
<Sample title="Separated" note="Source 작성 부분 간격">
<div className="facility-reference__row">
<LocalAssetFacility config={config} separated />
<LocalAssetFacility config={config} variant="actualDetail" separated />
</div>
</Sample>
<Sample title="너비 동작" note="Source 정의 가변 영역만 확장">
<div className="facility-reference__width-tests">
{widthNames.map((name, index) => (
<div key={name}><span>{['SHORT', 'MEDIUM', 'LONG'][index]}</span><LocalAssetFacility config={config} name={name} /></div>
))}
{widthNames.map((name, index) => (
<div key={`actual-${name}`}><span>{`ACTUAL ${['SHORT', 'REF', 'LONG'][index]}`}</span><LocalAssetFacility config={config} name={name} variant="actualDetail" /></div>
))}
</div>
</Sample>
<Sample title="간략 적용 예제" note={config.id === 'interchange' ? "SOURCE_PENDING · 기존 Preview 전용; 정확한 IC 구성 추론 안 함" : "SOURCE_RECEIVED · 관계 비교, 정확한 픽셀 정합 아님"}>
<CompactAppliedExample config={config} />
</Sample>
</div>
);
}
export function BridgeReferenceDemo() { return <FacilityReferenceDemo config={FACILITY_CONFIGS.bridge} />; }
export function IntersectionReferenceDemo() { return <FacilityReferenceDemo config={FACILITY_CONFIGS.intersection} />; }
export function JunctionReferenceDemo() { return <FacilityReferenceDemo config={FACILITY_CONFIGS.junction} />; }
export function InterchangeReferenceDemo() { return <FacilityReferenceDemo config={FACILITY_CONFIGS.interchange} />; }
export function RoadsideReferenceDemo() {
const tests: { size: string; entity: RoadsideEntity; entityLabel: string; label: string }[] = [
{ size: 'SHORT', entity: 'rest-area', entityLabel: "휴게소", label: '휴게소' },
{ size: 'REFERENCE', entity: 'drowsy-shelter', entityLabel: "졸음쉼터", label: '졸음쉼터' },
{ size: 'LONG', entity: 'drowsy-shelter', entityLabel: "졸음쉼터", label: '서해안고속도로 대산방향 졸음쉼터' },
];
return (
<div className="facility-reference roadside-reference" data-reference-facility="roadside">
<Sample title="Position Map" note="재구성 Primitive · 콘텐츠 기반 너비">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-test={test.size.toLowerCase()}>
<span className="roadside-tag-test__meta"><strong>{test.entityLabel}</strong><small>{test.size}</small></span>
<RoadsideFacilityTag entity={test.entity} label={test.label} />
</div>
))}
</div>
</Sample>
<Sample title="Actual / Detail" note="Source 작성 그라데이션/filter 그래픽 · 고정 양 끝 + 가변 중앙">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-actual-test={test.size.toLowerCase()}>
<span className="roadside-tag-test__meta"><strong>{test.entityLabel}</strong><small>{test.size}</small></span>
<RoadsideFacilityTag entity={test.entity} label={test.label} variant="actualDetail" />
</div>
))}
</div>
</Sample>
<Sample title="Actual / Detail · Separated" note="Source 작성 13 / 90 / 13 구조, 예제 간격 19 / 14">
<RoadsideFacilityTag entity="drowsy-shelter" variant="actualDetail" separated />
</Sample>
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 같은 시설물 태그 계약">
<RoadsideAppliedExample />
</Sample>
</div>
);
}
const LINKED_DEFINITIONS: Readonly<Record<string, { id: string; label: string; name: string; secondaryText: string; kind: FacilityKind; flexPadding: number; labelRegion: string; sourceReferenceTotals: FacilityConfig['sourceReferenceTotals'] }>> = Object.freeze({
'터널': { id: 'tunnel', label: "터널", name: '대산2터널', secondaryText: 'L=750m', kind: 'silhouette', flexPadding: 0, labelRegion: 'all', sourceReferenceTotals: { positionMap: 210, actualDetail: 220 } },
'교량': { id: 'bridge', label: "교량", name: '운산1교', secondaryText: 'L=200m', kind: 'silhouette', flexPadding: 10, labelRegion: 'all', sourceReferenceTotals: { positionMap: 197, actualDetail: 197 } },
'교차로': { id: 'intersection', label: "교차로", name: '신시교차로', secondaryText: '', kind: 'badge', flexPadding: 40, labelRegion: 'afterLeft', sourceReferenceTotals: { positionMap: 245, actualDetail: 255 } },
'분기점': { id: 'junction', label: 'JC', name: '당진분기점', secondaryText: '', kind: 'badge', flexPadding: 40, labelRegion: 'afterLeft', sourceReferenceTotals: { positionMap: 245, actualDetail: 255 } },
'나들목': { id: 'interchange', label: 'IC', name: '정미나들목', secondaryText: '', kind: 'badge', flexPadding: 40, labelRegion: 'afterLeft', sourceReferenceTotals: { positionMap: 245, actualDetail: 255 } },
});
const LINKED_GROUPS = Object.freeze([
{ roadClass: '고속도로, 국도', facilities: ['터널', '교량', '교차로', '분기점', '나들목'] },
{ roadClass: '지방도', facilities: ['터널', '교량', '교차로'] },
{ roadClass: '특별시도', facilities: ['터널', '교량', '교차로'] },
{ roadClass: '시도,군도', facilities: ['터널', '교량', '교차로'] },
{ roadClass: '구도,면도, 이도', facilities: ['터널', '교량', '교차로'] },
]);
function linkedPrefix(roadClass: string, facility: string) {
if (roadClass === '고속도로, 국도' && facility === '나들목') return `연계 시설물/${roadClass}/${facility}/시설물`;
return `연계 시설물/${roadClass}/${facility}`;
}
function linkedConfig(roadClass: string, facility: string): FacilityConfig | null {
const definition = LINKED_DEFINITIONS[facility]!;
const prefix = linkedPrefix(roadClass, facility);
const positionMap = getAssetSet(prefix, '위치도', true);
const actualDetail = getAssetSet(prefix, '실사화', true);
if (!positionMap || !actualDetail) return null;
return {
id: `linked-${definition.id}`,
label: `${roadClass} ${definition.label}`,
defaultName: definition.name,
secondaryText: definition.secondaryText,
kind: definition.kind,
flexPadding: definition.flexPadding,
sourceReferenceTotals: definition.sourceReferenceTotals,
labelRegion: definition.labelRegion,
namePlate: positionMap.pieces.length === 2 ? {
baseWidth: 126,
surface: facility === '교차로' ? '#444444' : '#777777',
border: LINKED_NAME_PLATE_BORDER,
tint: LINKED_NAME_PLATE_TINTS[roadClass],
tintOpacity: LINKED_NAME_PLATE_TINTS[roadClass] ? 0.1 : 0,
} : undefined,
routeMode: 'context',
gaps: {
positionMap: Array(positionMap.pieces.length - 1).fill(10),
actualDetail: Array(actualDetail.pieces.length - 1).fill(8),
},
variants: { positionMap, actualDetail },
};
}
function LinkedCard({ roadClass, facility }: { roadClass: string; facility: string }) {
const config = linkedConfig(roadClass, facility);
if (!config) return null;
return (
<article className="linked-facility__card" data-road-class={roadClass} data-linked-facility={facility}>
<header><strong>{LINKED_DEFINITIONS[facility]!.label}</strong><span>{facility}</span></header>
<div className="linked-facility__variant">
<small>POSITION</small><LocalAssetFacility config={config} /><LocalAssetFacility config={config} separated />
</div>
<div className="linked-facility__variant">
<small>ACTUAL</small><LocalAssetFacility config={config} variant="actualDetail" /><LocalAssetFacility config={config} variant="actualDetail" separated />
</div>
</article>
);
}
export function LinkedFacilityReferenceDemo() {
return (
<div className="linked-facility" data-reference-facility="linked">
<div className="linked-facility__context">
<span>사업 인접 노선 분류 맥락</span>
<i aria-hidden="true" />
<strong>낮은 채도의 로컬 자산 변형</strong>
</div>
{LINKED_GROUPS.map((group) => (
<section key={group.roadClass} className="linked-facility__group">
<header><h3>{group.roadClass}</h3><span>{group.facilities.length} READY 시설물 집합</span></header>
<div className="linked-facility__grid">
{group.facilities.map((facility) => <LinkedCard key={facility} roadClass={group.roadClass} facility={facility} />)}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,221 @@
.tunnel-reference {
width: 100%;
display: grid;
gap: 18px;
}
.tunnel-reference__variant-grid {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 14px;
}
.tunnel-reference__sample {
min-width: 0;
margin: 0;
overflow: hidden;
background: rgba(217, 217, 217, .48);
border: 1px solid rgba(64, 5, 0, .14);
border-radius: 8px;
}
.tunnel-reference__sample > header {
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 14px;
background: rgba(255, 255, 255, .62);
border-bottom: 1px solid rgba(64, 5, 0, .12);
}
.tunnel-reference__sample > header strong {
color: #400500;
font-size: 14px;
line-height: 20px;
}
.tunnel-reference__sample > header span {
color: #6e6759;
font-size: 12px;
line-height: 16px;
text-align: right;
}
.tunnel-reference__stage {
min-height: 154px;
display: grid;
place-items: center;
padding: 24px;
overflow-x: auto;
}
.tunnel-reference__row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 28px;
}
.tunnel-facility {
position: relative;
width: max-content;
height: var(--tunnel-height);
display: grid;
grid-template-columns:
var(--tunnel-left-width)
var(--tunnel-left-gap)
var(--tunnel-center-width)
var(--tunnel-right-gap)
var(--tunnel-right-width);
grid-template-rows: var(--tunnel-height);
margin: 0;
isolation: isolate;
flex: 0 0 auto;
}
.tunnel-facility__segment {
grid-row: 1;
display: block;
height: var(--tunnel-height);
overflow: hidden;
}
.tunnel-facility__segment img {
width: 100%;
height: 100%;
display: block;
user-select: none;
}
.tunnel-facility__segment--left {
grid-column: 1;
width: var(--tunnel-left-width);
z-index: 1;
}
.tunnel-facility__segment--center {
grid-column: 3;
width: var(--tunnel-center-width);
position: relative;
overflow: visible;
}
.tunnel-facility__segment--center img {
position: absolute;
top: 0;
left: 50%;
width: var(--tunnel-center-base-width);
max-width: none;
transform: translateX(-50%) scaleX(var(--tunnel-center-scale));
transform-origin: center;
}
/* Non-separated only: the fixed ends paint on top (z-index: 1), so a fractional-scaleX
compositing seam at the shared edge is hidden by extending this layer 1px under each
neighbor rather than by resizing the flex track itself. */
.tunnel-facility:not(.is-separated) .tunnel-facility__segment--center img {
transform: translateX(-50%) scaleX(calc((var(--tunnel-center-width) + 2px) / var(--tunnel-center-base-width)));
}
.tunnel-facility__segment--right {
grid-column: 5;
width: var(--tunnel-right-width);
z-index: 1;
}
.tunnel-facility__labels {
z-index: 1;
grid-column: 1 / -1;
grid-row: 1;
position: relative;
display: block;
height: var(--tunnel-height);
color: #fff;
text-align: center;
pointer-events: none;
}
/* Actual/Detail owns the available center track, not the full component. */
.tunnel-facility--actualDetail .tunnel-facility__labels {
grid-column: 3;
}
.tunnel-facility__primary,
.tunnel-facility__secondary {
position: absolute;
left: 50%;
display: block;
width: max-content;
max-width: none;
transform: translateX(-50%);
color: #fff;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 700;
text-align: center;
white-space: nowrap;
}
.tunnel-facility__primary {
top: 22px;
font-size: 30px;
line-height: 36px;
letter-spacing: -1.2px;
}
.tunnel-facility__secondary {
top: 68px;
font-size: 16px;
line-height: 19px;
letter-spacing: -.64px;
}
/* All Tunnel representations share vertical anchors; retain Actual typography. */
.tunnel-facility--actualDetail .tunnel-facility__secondary { line-height: 23px; }
.tunnel-facility__measure {
position: fixed;
inset: auto;
width: 0;
height: 0;
overflow: visible;
visibility: hidden;
pointer-events: none;
}
.tunnel-facility__measure .tunnel-facility__primary,
.tunnel-facility__measure .tunnel-facility__secondary {
position: fixed;
left: auto;
top: auto;
transform: none;
}
.tunnel-reference__width-tests {
width: 100%;
display: grid;
gap: 16px;
}
.tunnel-reference__width-tests > div {
min-width: max-content;
display: grid;
grid-template-columns: 64px max-content;
align-items: center;
gap: 16px;
}
.tunnel-reference__width-tests > div > span {
color: #6e6759;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
}
@media (max-width: 760px) {
.tunnel-reference__variant-grid { grid-template-columns: 1fr; }
.tunnel-reference__stage { justify-content: start; }
}
@@ -0,0 +1,212 @@
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" 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>
);
}