Initial commit: HMDesignSystem baseline
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user