Upgrade RFS and Saebyeok showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-08-25 04:41:51 +09:00
parent 9529e3747c
commit 413486958c
13 changed files with 1410 additions and 88 deletions

View file

@ -27,7 +27,8 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css" />
<link rel="stylesheet" href="./styles/tokens.css?v=2" />
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E1114'/%3E%3Cpath d='M4 21c4-6 8 4 12-2s8 3 12-3' stroke='%233E8FB0' stroke-width='2.4' fill='none' stroke-linecap='round'/%3E%3Cpath d='M4 27c4-6 8 4 12-2s8 3 12-3' stroke='%238CC152' stroke-width='2.4' fill='none' stroke-linecap='round' opacity='.75'/%3E%3C/svg%3E"
@ -101,6 +102,40 @@
시뮬레이터.
</p>
<div class="scenario-strip" role="group" aria-label="브라우저 데모 운전 장면">
<p class="scenario-label">운전 장면</p>
<button
class="scenario-btn"
type="button"
data-field-preset="baseline"
data-load="70"
data-supply="65"
aria-pressed="true"
>
기준 운전
</button>
<button
class="scenario-btn"
type="button"
data-field-preset="overload"
data-load="95"
data-supply="50"
aria-pressed="false"
>
랙 과부하
</button>
<button
class="scenario-btn"
type="button"
data-field-preset="supply-loss"
data-load="65"
data-supply="25"
aria-pressed="false"
>
급기 저하
</button>
</div>
<div class="hero-controls">
<div class="ctl">
<div class="ctl-head">
@ -118,6 +153,27 @@
</div>
</div>
<div class="field-readout" aria-live="polite" aria-atomic="true">
<div class="field-readout-head">
<p class="field-readout-label mono">브라우저 데모 해석</p>
<p class="field-readout-state" id="fieldState">냉기와 발열이 맞서는 기준 상태입니다.</p>
</div>
<dl class="field-readout-grid">
<div>
<dt>장면</dt>
<dd id="fieldScene">기준 운전</dd>
</div>
<div>
<dt>먼저 볼 곳</dt>
<dd id="fieldWatch">랙 배면</dd>
</div>
<div>
<dt>다음 조작</dt>
<dd id="fieldAction">부하와 급기를 따로 움직여 비교</dd>
</div>
</dl>
</div>
<p class="hero-note">
위 화면은 브라우저에서 도는 <strong>2D 축소판</strong>이다. 슬라이더를 움직이거나 화면을
문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 —
@ -329,28 +385,42 @@
</ol>
<div class="vis-shots">
<figure>
<img
src="./assets/sph-topview.webp"
width="689"
height="680"
loading="lazy"
decoding="async"
alt="RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다"
/>
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
</figure>
<figure>
<img
src="./assets/sph-sideview.webp"
width="691"
height="437"
loading="lazy"
decoding="async"
alt="RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면"
/>
<figcaption>측면 · 랙을 통과한 공기</figcaption>
</figure>
<div class="vis-toolbar">
<div class="vis-tabs" role="group" aria-label="RFS 실물 파티클 뷰 선택">
<button class="vis-tab" type="button" data-vis-target="top" aria-pressed="true">상면</button>
<button class="vis-tab" type="button" data-vis-target="side" aria-pressed="false">측면</button>
</div>
<p class="vis-source mono">RFS 실물 렌더</p>
</div>
<div class="vis-frame">
<figure data-vis-panel="top">
<img
src="./assets/sph-topview.webp"
width="689"
height="680"
loading="lazy"
decoding="async"
alt="RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다"
/>
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
</figure>
<figure data-vis-panel="side" hidden>
<img
src="./assets/sph-sideview.webp"
width="691"
height="437"
loading="lazy"
decoding="async"
alt="RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면"
/>
<figcaption>측면 · 랙을 통과한 공기</figcaption>
</figure>
</div>
<div class="temp-key" role="list" aria-label="파티클 온도 색상 범례">
<span role="listitem"><i class="temp-cold" aria-hidden="true"></i>저온</span>
<span role="listitem"><i class="temp-mid" aria-hidden="true"></i>중간</span>
<span role="listitem"><i class="temp-hot" aria-hidden="true"></i>고온</span>
</div>
<p class="shots-note">
두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
이 페이지 첫 화면의 색도 같은 규칙을 쓴다.

View file

@ -34,6 +34,101 @@
const shell = canvas.closest("[data-field-shell]");
const setState = (s) => shell && shell.setAttribute("data-field-state", s);
/* ── 데모 조작 UI ────────────────────────────────────────────────
WebGL 이 없거나 모션을 줄여도 조작의 의미는 남아야 한다.
그래서 슬라이더·장면·실물 뷰 전환은 그래픽 초기화보다 먼저 묶는다. */
const controls = {
load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100,
supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100,
};
const presetButtons = [...document.querySelectorAll("[data-field-preset]")];
const fieldState = document.getElementById("fieldState");
const fieldScene = document.getElementById("fieldScene");
const fieldWatch = document.getElementById("fieldWatch");
const fieldAction = document.getElementById("fieldAction");
const activePreset = () => presetButtons.find((button) => button.getAttribute("aria-pressed") === "true");
function updateReadout() {
const gap = Math.round((controls.load - controls.supply) * 100);
const selected = activePreset();
if (fieldScene) fieldScene.textContent = selected?.textContent.trim() || "수동 조정";
let state = "냉기와 발열이 맞서는 기준 상태입니다.";
let watch = "랙 배면";
let action = "부하와 급기를 따로 움직여 비교";
if (gap >= 35) {
state = "발열이 급기보다 크게 앞섭니다. 배면에서 천장으로 오르는 열기둥을 확인하십시오.";
watch = "랙 배면 · 천장 배기";
action = "급기 상향과 부하 재배분을 비교";
} else if (gap >= 15) {
state = "발열 우세 구간입니다. 랙 뒤에 머무는 고온 입자의 경로를 비교하십시오.";
watch = "랙 배면";
action = "급기량을 한 단계씩 높여 비교";
} else if (gap <= -15) {
state = "급기가 발열을 앞섭니다. FWU 제트가 만드는 저온 구간을 확인하십시오.";
watch = "FWU 제트 · 냉점";
action = "과냉 구간과 팬 운전을 함께 검토";
}
if (fieldState) fieldState.textContent = state;
if (fieldWatch) fieldWatch.textContent = watch;
if (fieldAction) fieldAction.textContent = action;
}
function matchPreset() {
const load = Math.round(controls.load * 100);
const supply = Math.round(controls.supply * 100);
let matched = false;
for (const button of presetButtons) {
const same = Number(button.dataset.load) === load && Number(button.dataset.supply) === supply;
button.setAttribute("aria-pressed", String(same));
matched ||= same;
}
if (!matched) presetButtons.forEach((button) => button.setAttribute("aria-pressed", "false"));
}
function bindSlider(id, key, outId, fmt) {
const el = document.getElementById(id);
const out = document.getElementById(outId);
if (!el) return;
const sync = () => {
controls[key] = Number(el.value) / 100;
if (out) out.textContent = fmt(Number(el.value));
matchPreset();
updateReadout();
};
el.addEventListener("input", sync);
sync();
}
bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`);
bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`);
for (const button of presetButtons) {
button.addEventListener("click", () => {
const load = document.getElementById("ctlLoad");
const supply = document.getElementById("ctlSupply");
if (!(load instanceof HTMLInputElement) || !(supply instanceof HTMLInputElement)) return;
load.value = button.dataset.load || load.value;
supply.value = button.dataset.supply || supply.value;
load.dispatchEvent(new Event("input", { bubbles: true }));
supply.dispatchEvent(new Event("input", { bubbles: true }));
});
}
const visTabs = [...document.querySelectorAll("[data-vis-target]")];
const visPanels = [...document.querySelectorAll("[data-vis-panel]")];
for (const tab of visTabs) {
tab.addEventListener("click", () => {
const target = tab.dataset.visTarget;
for (const button of visTabs) button.setAttribute("aria-pressed", String(button === tab));
for (const panel of visPanels) panel.hidden = panel.dataset.visPanel !== target;
});
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduced.matches) {
setState("static");
@ -450,27 +545,6 @@
}
}
/* ── 조작 입력 ────────────────────────────────────────────────── */
const controls = {
load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100,
supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100,
};
function bindSlider(id, key, outId, fmt) {
const el = document.getElementById(id);
const out = document.getElementById(outId);
if (!el) return;
const sync = () => {
controls[key] = Number(el.value) / 100;
if (out) out.textContent = fmt(Number(el.value));
};
el.addEventListener("input", sync);
sync();
}
bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`);
bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`);
const pointer = { x: 0, y: 0, on: 0 };
const onMove = (e) => {
const r = canvas.getBoundingClientRect();

View file

@ -1,5 +1,3 @@
@import "./tokens.css";
/* ==========================================================================
RFS — 메인 스타일시트
@ -361,8 +359,48 @@ h3 {
color: var(--band-ink-muted);
}
.hero-controls {
.scenario-strip {
margin-top: var(--space-5);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
max-width: 760px;
}
.scenario-label {
margin-right: var(--space-2);
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--band-ink-faint);
}
.scenario-btn,
.vis-tab {
min-height: 40px;
padding-inline: var(--space-3);
border: 1px solid var(--band-line-strong);
background: transparent;
color: var(--band-ink-muted);
cursor: pointer;
font-size: var(--step--1);
}
.scenario-btn:hover,
.vis-tab:hover {
color: var(--band-ink);
border-color: var(--band-accent);
}
.scenario-btn[aria-pressed="true"],
.vis-tab[aria-pressed="true"] {
color: var(--band-accent-ink);
background: var(--band-accent);
border-color: var(--band-accent);
}
.hero-controls {
margin-top: var(--space-4);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
@ -389,6 +427,58 @@ input[type="range"] {
background: transparent;
}
.field-readout {
margin-top: var(--space-5);
max-width: 880px;
border-block: 1px solid var(--band-line-strong);
}
.field-readout-head {
display: grid;
gap: var(--space-2);
padding-block: var(--space-3);
border-bottom: 1px solid var(--band-line);
}
.field-readout-label,
.field-readout-grid dt {
font-size: var(--step--1);
color: var(--band-ink-faint);
}
.field-readout-state {
color: var(--band-ink);
}
.field-readout-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field-readout-grid > div {
min-width: 0;
padding-block: var(--space-3);
}
.field-readout-grid > div + div {
padding-left: var(--space-3);
border-left: 1px solid var(--band-line);
}
.field-readout-grid dd {
margin-top: var(--space-1);
color: var(--band-ink-muted);
font-size: var(--step--1);
line-height: var(--leading-normal);
}
@media (min-width: 720px) {
.field-readout-head {
grid-template-columns: 9rem minmax(0, 1fr);
align-items: baseline;
}
}
.hero-note {
margin-top: var(--space-5);
padding-top: var(--space-3);
@ -692,17 +782,54 @@ input[type="range"] {
.vis-shots {
display: grid;
gap: var(--space-4);
gap: var(--space-3);
min-width: 0;
}
.vis-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border-bottom: 1px solid var(--band-line-strong);
padding-bottom: var(--space-2);
}
.vis-tabs {
display: flex;
}
.vis-tab + .vis-tab {
border-left: 0;
}
.vis-source {
color: var(--band-ink-faint);
font-size: var(--step--1);
text-align: right;
}
.vis-frame {
min-width: 0;
}
.vis-shots figure {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
border: 1px solid var(--band-line);
background: var(--band-surface-2);
min-height: clamp(360px, 42vw, 540px);
}
.vis-shots figure[hidden] {
display: none;
}
.vis-shots img {
width: 100%;
height: auto;
height: 100%;
min-height: 0;
object-fit: contain;
}
.vis-shots figcaption {
@ -713,6 +840,35 @@ input[type="range"] {
font-family: var(--font-mono);
}
.temp-key {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
color: var(--band-ink-muted);
font-family: var(--font-mono);
font-size: var(--step--1);
}
.temp-key span {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.temp-key i {
width: 1.5rem;
height: 2px;
background: var(--temp-cold);
}
.temp-key .temp-mid {
background: var(--temp-mid);
}
.temp-key .temp-hot {
background: var(--temp-hot);
}
.shots-note {
font-size: var(--step--1);
color: var(--band-ink-faint);
@ -979,6 +1135,35 @@ kbd {
font-size: 0.6875rem;
}
.scenario-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.scenario-label {
grid-column: 1 / -1;
}
.scenario-btn,
.vis-tab {
min-height: 44px;
padding-inline: var(--space-2);
}
.field-readout-grid {
grid-template-columns: 1fr;
}
.field-readout-grid > div + div {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--band-line);
}
.vis-shots figure {
min-height: 320px;
}
.figures {
grid-template-columns: repeat(2, 1fr);
}
@ -1014,6 +1199,8 @@ kbd {
.site-head,
.hero-stage,
.hero-controls,
.scenario-strip,
.field-readout,
.skip-link {
display: none;
}