From acf00249935c7451188abeca3fdbdf62f897b08e Mon Sep 17 00:00:00 2001
From: RosieOh
Date: Tue, 22 Sep 2026 17:54:49 +0900
Subject: [PATCH 1/4] =?UTF-8?q?refactor(content):=20=EC=97=AC=EB=9F=AC=20?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EA=B0=80=20=EC=9D=B8=EC=9A=A9?=
=?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=8C=90=EB=8B=A8=20=EB=AC=B8=EA=B5=AC?=
=?UTF-8?q?=EB=A5=BC=20=ED=95=9C=20=EA=B3=B3=EC=9C=BC=EB=A1=9C=20=EB=AA=A8?=
=?UTF-8?q?=EC=9D=80=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
홈의 스크롤 구간과 /benefits · /facilities · /data 가 같은 문장(자격 판정 세 갈래, 알리지 않는 경우, 아는 것/모르는 것)을 보여 주게 된다. 한쪽만 고쳐 두 페이지가 다른 말을 하지 않도록 src/content/facts.ts 로 옮긴다. 수치는 자동 갱신 스크립트가 고치는 figures.ts 에 그대로 둔다.
---
src/app/benefits/page.tsx | 19 +-----------
src/app/data/page.tsx | 26 ++---------------
src/app/facilities/page.tsx | 8 +----
src/content/facts.ts | 58 +++++++++++++++++++++++++++++++++++++
4 files changed, 62 insertions(+), 49 deletions(-)
create mode 100644 src/content/facts.ts
diff --git a/src/app/benefits/page.tsx b/src/app/benefits/page.tsx
index 56ceff4..ea263da 100644
--- a/src/app/benefits/page.tsx
+++ b/src/app/benefits/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import Link from 'next/link'
import { ReactNode } from 'react'
import { Figure, PageHead, Section } from '@/components/Page'
+import { VERDICTS } from '@/content/facts'
export const metadata: Metadata = {
title: '지원금',
@@ -9,24 +10,6 @@ export const metadata: Metadata = {
'놓친 지원금 찾기, 거주지별 비교, 실수령액 제보로 확정하는 금액. 추정치는 추정치라고 표기합니다.',
}
-/** 자격 판정 결과. 소득 미입력을 탈락으로 처리하지 않는 것이 이 서비스의 판단이다. */
-const VERDICTS = [
- {
- tone: 'var(--seal-deep)',
- head: '대상입니다',
- body: '자녀 수와 소득 조건을 모두 만족합니다. 예상 총액에 넣습니다.',
- },
- {
- tone: 'var(--past)',
- head: '판단 보류',
- body: '소득을 입력하지 않으셨습니다. 탈락으로 처리하면 받을 수 있었던 돈이 목록에서 통째로 사라지므로, 보류로 두고 함께 보여 드립니다.',
- },
- {
- tone: 'var(--stamp)',
- head: '대상 아닙니다',
- body: '자녀 수가 모자라거나 소득이 기준을 넘습니다. 왜 아닌지 함께 적습니다.',
- },
-]
export default function BenefitsPage(): ReactNode {
return (
diff --git a/src/app/data/page.tsx b/src/app/data/page.tsx
index c602d54..4524905 100644
--- a/src/app/data/page.tsx
+++ b/src/app/data/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { ReactNode } from 'react'
import { PageHead, Section, Shell } from '@/components/Page'
+import { KNOWN_UNKNOWN } from '@/content/facts'
import { MEASURED_LABEL, MEASURED_ON, SOURCES, SYNC_SCHEDULE, formatCount } from '@/content/figures'
export const metadata: Metadata = {
@@ -9,29 +10,6 @@ export const metadata: Metadata = {
'어디서 받아 오는지, 무엇을 알 수 있고 무엇을 알 수 없는지. 비어 있는 지역과 못 채운 값을 그대로 적었습니다.',
}
-/** 이 표가 이 페이지의 이유다. 마케팅 페이지가 보통 쓰지 않는 오른쪽 칸. */
-const LEDGER = [
- {
- known: '시설 전체의 정원과 현원',
- unknown: '반별 정원',
- why: '공공데이터가 시설 단위로만 줍니다. 0세반이 찼는데 5세반에 자리가 난 것일 수 있어, 알림에 그대로 적습니다.',
- },
- {
- known: '정책의 대상 연령·지역·소득 기준',
- unknown: '일부 정책의 지원 금액',
- why: '금액이 숫자가 아니라 설명 문장 안에 섞여 옵니다. 기계로 뽑다 실패한 건 미상으로 두고, 받아 보신 분들의 제보로 채웁니다.',
- },
- {
- known: '관측된 정원 변동',
- unknown: '대기 순번이 도는 시점',
- why: '정부가 공개하지 않습니다. 부모들이 남긴 입소 기록이 세 건 이상 모인 시설만 통계를 냅니다.',
- },
- {
- known: '전국 시군구 200여 곳의 시설',
- unknown: '광주와 전남의 시설',
- why: '두 정부 API 모두 이 두 지역에서 빈 결과를 돌려줍니다. 우리 쪽 문제가 아니라 확인된 사실이라 적어 둡니다.',
- },
-]
export default function DataPage(): ReactNode {
return (
@@ -126,7 +104,7 @@ export default function DataPage(): ReactNode {
- 육아 지원금은 중앙정부와 지자체가 따로 운영합니다. 대상인지 알기 어렵고, 신청 기한을
- 넘기면 조용히 사라집니다. 옆의 축을 옮겨 보면 아이 개월 수마다 무엇이 열리고
- 닫히는지 한눈에 보입니다.
-
-
-
-
- 지원금 찾는 방법
-
-
- 데이터 출처 보기
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[
- {
- head: '놓친 지원금',
- body: '아이가 지나온 개월 구간을 거슬러 올라가 대상이었던 지원금을 찾습니다. 소급 신청이 아직 가능한 것과 이미 지난 것을 나눠서 보여 줍니다.',
- foot: '이미 지난 것도 보여 주는 이유는, 둘째를 준비하는 부모에게는 그게 중요한 정보이기 때문입니다.',
- },
- {
- head: '어린이집 빈자리',
- body: '대기 걸어 둔 시설의 정원을 매주 관측해 자리가 늘어난 순간에만 알립니다. 계속 자리가 있는 곳은 이미 알고 계실 테니 알리지 않습니다.',
- foot: '어느 반에 자리가 났는지는 공공데이터가 주지 않아, 알림에 그 한계를 적어 보냅니다.',
- },
- {
- head: '신청 마감',
- body: '조건에 맞는 지원금의 신청 기한이 일주일 남았을 때, 그리고 하루 남았을 때 알립니다.',
- foot: '놓친 뒤에 알려 주는 것보다 놓치기 전에 막는 편이 낫습니다.',
- },
- ].map((card) => (
-
-
{card.head}
-
- {card.body}
-
-
- {card.foot}
-
-
- ))}
-
-
-
-
-
- {SOURCES.map((source) => (
-
- ))}
-
-
- 네 곳 모두 응답 형식과 지역 코드 체계가 다릅니다. 시군구 200여 곳을 하나씩 돌면서 모으고,
- 한 곳이 실패해도 나머지는 계속 받습니다. 지금 비어 있는 지역과 아직 못 채운 값은{' '}
-
- 데이터 페이지
-
- 에 그대로 적어 두었습니다.
-
-
-
- {/* 신뢰 구획. 이 서비스가 다른 곳과 갈리는 지점이라 별도로 세운다. */}
-
-
-
- 지원금 금액은 정부가 숫자로 주지 않고 설명 문장 안에 섞여 옵니다. 그걸 기계로 뽑다 보면
- 틀립니다. 틀린 금액을 확정치처럼 보여 주면 그건 신뢰 문제를 넘어 분쟁이 됩니다.
-
-
- 그래서 확실하지 않은 값은 미상으로 두고, 표본이
- 부족한 통계는 숫자 대신 이유를 돌려 드립니다. 받아 본 분들의 실수령액 제보가 세 건
- 모이면 그때 금액을 확정합니다.
-
-
-
-
- 관측 기반
-
-
- 추정치 표기
-
-
- 출처 공개
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
>
)
}
diff --git a/src/features/grow/GrowScene.tsx b/src/features/grow/GrowScene.tsx
new file mode 100644
index 0000000..edc93ff
--- /dev/null
+++ b/src/features/grow/GrowScene.tsx
@@ -0,0 +1,251 @@
+'use client'
+
+/*
+ * 스크롤 = 아이의 개월 수.
+ *
+ * 이 컴포넌트가 하는 일은 셋뿐이다.
+ * 1) 스크롤 위치를 개월 수로 바꿔 동네 장면에 넘긴다
+ * 2) 개월 레일의 눈금·현재 개월 수를 맞춘다
+ * 3) 동네가 가려지는 구간에서는 렌더 루프를 세운다
+ *
+ * 개월 수는 문서 진행도를 그대로 쓰지 않는다. 각 구간([data-month])이 화면 가운데에 온 순간의
+ * 스크롤 위치를 닻으로 삼아 그 사이를 잇는다 — 그래야 도장에 찍힌 "18개월"을 읽는 순간 장면도
+ * 정확히 18개월이다. 구간 길이가 제각각이라 진행도를 선형으로 나누면 도장과 장면이 어긋난다.
+ *
+ * 전부 명령형으로 둔다. 스크롤은 초당 60회 도는 경로라 여기에 React 상태를 얹으면
+ * 프레임마다 리렌더가 돈다. React 가 소유하는 것은 "레일이 어떤 DOM 인가"까지다.
+ */
+
+import { ReactNode, useEffect, useRef } from 'react'
+import type { TownHandle } from '../town/types'
+import { STAGES, monthLabel } from './stages'
+
+type Anchor = { el: HTMLElement; tick?: HTMLElement; month: number; y: number }
+
+export default function GrowScene(): ReactNode {
+ const skyRef = useRef(null)
+ const scrimRef = useRef(null)
+ const railRef = useRef(null)
+ const nowRef = useRef(null)
+ const ticksRef = useRef(null)
+
+ useEffect(() => {
+ const html = document.documentElement
+ const sky = skyRef.current
+ const scrim = scrimRef.current
+ const rail = railRef.current
+ const railNow = nowRef.current
+ const ticksHost = ticksRef.current
+ if (!sky || !rail || !railNow || !ticksHost) return
+
+ let town: TownHandle | null = null
+ let disposed = false
+ let ticking = false
+ let skyVisible = true
+ let shownLabel = ''
+
+ const tickEls = Array.from(ticksHost.querySelectorAll('.yr-rail__tick'))
+ const anchors: Anchor[] = Array.from(document.querySelectorAll('[data-month]')).map(
+ (el, i) => ({ el, tick: tickEls[i], month: Number(el.dataset.month ?? 0), y: 0 }),
+ )
+
+ if (process.env.NODE_ENV !== 'production' && anchors.length !== tickEls.length) {
+ console.warn(
+ `[맘편한] 구간 ${anchors.length}개와 레일 눈금 ${tickEls.length}개가 다릅니다. ` +
+ 'src/features/grow/stages.ts 와 홈의 [data-month] 구간을 맞추세요.',
+ )
+ }
+
+ /* ── 닻 — 구간 머리가 화면 가운데에 오는 스크롤 위치 ───────────── */
+ const isNarrow = () => window.matchMedia('(max-width: 900px)').matches
+
+ function measure() {
+ const docH = Math.max(1, html.scrollHeight - window.innerHeight)
+ const narrow = isNarrow()
+ for (const a of anchors) {
+ const top = a.el.getBoundingClientRect().top + window.scrollY
+ a.y = Math.max(0, top - window.innerHeight * 0.5)
+ if (!a.tick) continue
+ const pct = `${(Math.min(1, a.y / docH) * 100).toFixed(2)}%`
+ a.tick.style.left = narrow ? pct : ''
+ a.tick.style.top = narrow ? '' : pct
+ }
+ }
+
+ function monthAt(y: number): number {
+ if (anchors.length === 0) return 0
+ if (y <= anchors[0].y) return anchors[0].month
+ for (let i = 0; i < anchors.length - 1; i++) {
+ const a = anchors[i]
+ const b = anchors[i + 1]
+ if (y <= b.y) {
+ const span = b.y - a.y
+ return span > 0 ? a.month + ((y - a.y) / span) * (b.month - a.month) : b.month
+ }
+ }
+ return anchors[anchors.length - 1].month
+ }
+
+ /* ── 매 프레임 ─────────────────────────────────────────── */
+ function update() {
+ ticking = false
+ const docH = Math.max(1, html.scrollHeight - window.innerHeight)
+ const y = window.scrollY
+ const month = monthAt(y)
+
+ rail!.style.setProperty('--rd', Math.min(1, y / docH).toFixed(4))
+ town?.setMonth(month)
+
+ const label = monthLabel(Math.round(month))
+ if (label !== shownLabel) {
+ railNow!.textContent = label
+ shownLabel = label
+ }
+ for (const a of anchors) if (a.tick) a.tick.dataset.on = y >= a.y - 1 ? '1' : '0'
+ }
+
+ function onScroll() {
+ if (ticking) return
+ ticking = true
+ requestAnimationFrame(update)
+ }
+
+ function onResize() {
+ measure()
+ onScroll()
+ }
+
+ /* ── 동네가 보이는 구간 ────────────────────────────────── */
+ const seen = new Set()
+ const openObserver = new IntersectionObserver(
+ (entries) => {
+ for (const e of entries) {
+ if (e.isIntersecting) seen.add(e.target)
+ else seen.delete(e.target)
+ }
+ const visible = seen.size > 0
+ if (visible === skyVisible) return
+ skyVisible = visible
+ const op = visible ? '1' : '0'
+ sky!.style.setProperty('--sky-op', op)
+ scrim?.style.setProperty('--sky-op', op)
+ town?.setVisible(visible)
+ },
+ { rootMargin: '12% 0px' },
+ )
+ document.querySelectorAll('[data-open]').forEach((s) => openObserver.observe(s))
+
+ /* ── 등장 — 한 가지 방식만 ─────────────────────────────── */
+ const revealObserver = new IntersectionObserver(
+ (entries, obs) => {
+ for (const e of entries) {
+ if (!e.isIntersecting) continue
+ e.target.classList.add('is-in')
+ obs.unobserve(e.target)
+ }
+ },
+ { rootMargin: '0px 0px -8% 0px', threshold: 0.08 },
+ )
+ document.querySelectorAll('[data-in]').forEach((el) => {
+ /* 형제끼리만 조금씩 늦춘다 — 전역 순번으로 늦추면 아래쪽이 영영 안 뜬다 */
+ const nth = el.parentElement ? Array.from(el.parentElement.children).indexOf(el) : 0
+ el.style.setProperty('--d', `${Math.min(nth, 6) * 55}ms`)
+ revealObserver.observe(el)
+ })
+
+ /* ── 동네 장면 — 동적 import ────────────────────────────
+ 쓰지 않을 것이 확실하면 아예 받지 않는다. three.js 청크를 받아 파싱한 뒤에
+ 확인하면, 동작 줄이기를 켠 사용자에게는 통째로 버리는 다운로드가 된다. */
+ const wantsMotion = !window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ const canWebGL2 = (() => {
+ try {
+ return !!(window.WebGL2RenderingContext && document.createElement('canvas').getContext('webgl2'))
+ } catch {
+ return false
+ }
+ })()
+
+ const showFallback = (reason: string) => {
+ const fb = sky.querySelector('[data-fallback]')
+ if (fb) fb.hidden = false
+ sky.setAttribute('data-fallback-reason', reason)
+ }
+
+ if (!wantsMotion || !canWebGL2) {
+ showFallback(wantsMotion ? 'no-webgl2' : 'reduced-motion')
+ if (process.env.NODE_ENV !== 'production') {
+ console.info(
+ '[맘편한] 3D 동네를 건너뛰고 정적 배경을 씁니다 — ' +
+ (wantsMotion
+ ? '이 브라우저에서 WebGL2 를 쓸 수 없습니다. chrome://gpu 를 확인하세요.'
+ : '브라우저가 prefers-reduced-motion: reduce 를 보고합니다. ' +
+ 'Windows: 설정 > 접근성 > 시각 효과 > 애니메이션 효과 / macOS: 손쉬운 사용 > 디스플레이 > 동작 줄이기'),
+ )
+ }
+ } else {
+ ;(async () => {
+ try {
+ const mod = await import('../town/town-scene')
+ if (disposed) return
+ town = mod.mount(sky)
+ town.setVisible(skyVisible)
+ /* 프레이밍을 다시 잡을 때 콘솔에서 carecodeTown.probe() 로 건물의 화면 좌표를 잰다.
+ 값을 짐작으로 넣지 않기 위한 손잡이다. */
+ ;(window as unknown as { carecodeTown?: TownHandle }).carecodeTown = town
+ update()
+ } catch (err) {
+ showFallback('load-failed')
+ console.warn('동네 장면을 불러오지 못했습니다 — 정적 배경으로 대체합니다.', err)
+ }
+ })()
+ }
+
+ measure()
+ update()
+ window.addEventListener('scroll', onScroll, { passive: true })
+ window.addEventListener('resize', onResize)
+ window.addEventListener('load', onResize)
+ /* 폰트·이미지가 늦게 붙으면 구간 높이가 바뀐다. 닻을 다시 잰다 */
+ const ro = new ResizeObserver(onResize)
+ ro.observe(document.body)
+
+ return () => {
+ disposed = true
+ window.removeEventListener('scroll', onScroll)
+ window.removeEventListener('resize', onResize)
+ window.removeEventListener('load', onResize)
+ ro.disconnect()
+ openObserver.disconnect()
+ revealObserver.disconnect()
+ town?.dispose()
+ town = null
+ }
+ }, [])
+
+ return (
+ <>
+ {/* 여섯 해가 지나가는 동네. 글자는 전부 DOM 이라 SEO·스크린리더 손실이 없다. */}
+
+
+
+
+
+ {/* 개월 레일 — 시간 순서가 정보 자체라서 개월 표기가 장식이 아니다 */}
+
+ 출생
+
+ {monthLabel(0)}
+
+
+
+
+ {STAGES.map((s) => (
+
+ ))}
+
+
+ 취학 전
+
+ >
+ )
+}
diff --git a/src/features/grow/stages.ts b/src/features/grow/stages.ts
new file mode 100644
index 0000000..f58cd6f
--- /dev/null
+++ b/src/features/grow/stages.ts
@@ -0,0 +1,31 @@
+/**
+ * 여섯 해의 정거장. 개월 레일의 눈금 개수가 이 목록에서 나온다.
+ *
+ * 홈(src/app/page.tsx)의 [data-month] 구간과 순서·개수가 같아야 한다. 레일은 런타임에
+ * [data-month] 를 다시 질의해 눈금과 짝을 맞추므로, 여기가 어긋나면 눈금이 남거나 모자란다.
+ *
+ * `open` 은 그 구간의 지면을 비워 뒤의 동네(3D)가 보이게 한다는 뜻이다.
+ * 동네가 그 시기의 주인공일 때만 연다 — 전부 열면 글이 풍경에 묻힌다.
+ */
+export type Stage = {
+ id: string
+ /** 그 구간이 화면 가운데에 왔을 때 아이의 개월 수 */
+ month: number
+ open: boolean
+}
+
+export const STAGES: Stage[] = [
+ { id: 'top', month: 0, open: true },
+ { id: 'm01', month: 1, open: false },
+ { id: 'm06', month: 6, open: true },
+ { id: 'm12', month: 12, open: false },
+ { id: 'm18', month: 18, open: true },
+ { id: 'm24', month: 24, open: false },
+ { id: 'm36', month: 36, open: false },
+ { id: 'm48', month: 48, open: false },
+ { id: 'm60', month: 60, open: false },
+ { id: 'm72', month: 72, open: true },
+]
+
+/** 레일과 도장에 쓰는 표기. 레일 위아래 끝에 '출생' · '취학 전'이 이미 적혀 있어 0도 숫자로 쓴다. */
+export const monthLabel = (m: number): string => `${Math.max(0, m)}개월`
diff --git a/src/features/stations/Close.tsx b/src/features/stations/Close.tsx
new file mode 100644
index 0000000..4736773
--- /dev/null
+++ b/src/features/stations/Close.tsx
@@ -0,0 +1,50 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+import { monthLabel } from '@/features/grow/stages'
+
+/*
+ * 72개월 — 취학을 앞둔 봄. 동네가 마지막으로 열리고 카메라가 물러나 여섯 해 동안 드나든
+ * 건물을 한 화면에 담는다. 태어난 봄에 심은 나무가 동네 나무만큼 자라 있다.
+ */
+const DOCS = [
+ { href: '/benefits', label: '지원금 — 자격 판정과 계산' },
+ { href: '/facilities', label: '어린이집 — 빈자리 알림 설계' },
+ { href: '/data', label: '데이터 — 출처와 한계' },
+ { href: '/trust', label: '개인정보 — 받는 것과 그 이유' },
+] as const
+
+export function Close(): ReactNode {
+ return (
+
+
+
+
+
{monthLabel(72)}
+
+ 여섯 해가 지나고,
+
+ 놓친 것 대신 기록이 남습니다.
+
+
+ 태어난 봄에 심은 나무가 동네 나무만큼 자랐습니다. 그동안 열렸다 닫힌 지원과 드나든
+ 건물이 한 화면에 들어옵니다. 판단의 근거는 전부 문서로 남겨 두었습니다.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/stations/Hero.tsx b/src/features/stations/Hero.tsx
new file mode 100644
index 0000000..c3f29d7
--- /dev/null
+++ b/src/features/stations/Hero.tsx
@@ -0,0 +1,34 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+
+/* 출생. 동네가 처음 보이는 자리라 지면을 비운다. 카피는 기존 홈의 문장을 그대로 잇는다. */
+export function Hero(): ReactNode {
+ return (
+
+
+
+
육아 지원 안내 · CARECODE
+
+ 받을 수 있었는데
+
+ 몰라서 못 받은 돈
+
+
+ 육아 지원금은 중앙정부와 지자체가 따로 운영합니다. 대상인지 알기 어렵고, 신청 기한을
+ 넘기면 조용히 사라집니다. 맘편한은 아이의 개월 수를 따라가며 그때 열리는 것과 닫히는 것을
+ 알려 드립니다.
+
+
+ )
+}
diff --git a/src/features/stations/Month01.tsx b/src/features/stations/Month01.tsx
new file mode 100644
index 0000000..81a23ab
--- /dev/null
+++ b/src/features/stations/Month01.tsx
@@ -0,0 +1,77 @@
+import { ReactNode } from 'react'
+import { MEASURED_LABEL, SOURCES, formatCount } from '@/content/figures'
+import { Station, StationHead } from './Station'
+
+/* 1개월 — 출생신고를 마친 달. 지원이 없어서가 아니라 있는 자리가 다르다는 것부터 말한다. */
+
+const GAP = [
+ { k: '운영', policy: '중앙정부와 지자체가 따로', parent: '우리 집 한 곳' },
+ { k: '기준', policy: '대상 연령 상·하한 · 소득 · 자녀 수', parent: '우리 아이 개월 수' },
+ { k: '금액', policy: '설명 문장 안에 섞여 옴', parent: '그래서 얼마인가' },
+ { k: '시간', policy: '신청 기한이 지나면 사라짐', parent: '지금 신청할 수 있나' },
+] as const
+
+export function Month01(): ReactNode {
+ return (
+
+
+ 지원이 없어서가 아닙니다.
+
+ 있는 자리가 다를 뿐입니다.
+ >
+ }
+ >
+
+ 지원은 이미 정부가 만들고 있습니다. 다만 그것이 존재하는 단위와, 아이를 안은 부모가 서
+ 있는 단위가 다릅니다. 맘편한이 하는 일은 새 지원을 만드는 게 아니라{' '}
+ 이 간극을 메우는 것입니다.
+
+
+
+
+
+
+ 항목
+
+ 지원이 존재하는 단위
+ 부모가 서 있는 단위
+
+ {GAP.map((row) => (
+
+
+ {row.k}
+
+ {row.policy}
+
+ {row.parent}
+
+
+ ))}
+
+
+
+ 네 곳의 정부 데이터를 한 자리에 {MEASURED_LABEL}
+
+
+ {SOURCES.map((s) => (
+
+
+ {formatCount(s.count)}
+ {s.unit}
+
+
{s.gets}
+
{s.name}
+
+ ))}
+
+
+ 네 곳 모두 응답 형식과 지역 코드 체계가 다릅니다. 시군구 200여 곳을 하나씩 돌면서 모으고, 한
+ 곳이 실패해도 나머지는 계속 받습니다.
+
+
+ )
+}
diff --git a/src/features/stations/Month06.tsx b/src/features/stations/Month06.tsx
new file mode 100644
index 0000000..55f0ffa
--- /dev/null
+++ b/src/features/stations/Month06.tsx
@@ -0,0 +1,48 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+import VacancyNotice from '@/components/VacancyNotice'
+import { SILENT_CASES } from '@/content/facts'
+import { monthLabel } from '@/features/grow/stages'
+
+/*
+ * 6개월 — 어린이집 대기를 거는 무렵. 동네가 열리고 카메라가 어린이집 앞마당으로 간다.
+ * 이 구간의 주인공은 풍경이라 글은 종이 판 하나에 모은다.
+ */
+export function Month06(): ReactNode {
+ return (
+
+
+
+
{monthLabel(6)}
+
+ 자리가 났다는 걸
+
+ 아무도 알려 주지 않습니다
+
+
+ 대기를 걸어 두면 그다음은 기다리는 일뿐입니다. 맘편한은 대기 걸어 둔 시설의 정원을 매주
+ 관측해 자리가 늘어난 순간에만 알립니다.
+
+
+
+
+
+
+
알리지 않는 경우
+
+ {SILENT_CASES.map((c) => (
+
+
{c.when}
+
{c.why}
+
+ ))}
+
+
+ 알림은 앱을 다시 열 유일한 이유이자, 성가시면 앱을 지우게 되는 이유입니다.{' '}
+ 빈자리 알림 설계 보기
+
+
+
+
+ )
+}
diff --git a/src/features/stations/Month12.tsx b/src/features/stations/Month12.tsx
new file mode 100644
index 0000000..7b843c0
--- /dev/null
+++ b/src/features/stations/Month12.tsx
@@ -0,0 +1,40 @@
+import { ReactNode } from 'react'
+import MonthTimeline from '@/components/MonthTimeline'
+import { Station, StationHead } from './Station'
+
+/*
+ * 12개월 — 첫 돌. 첫만남이용권의 대상 기간(0–11개월)이 여기서 끝난다.
+ * 이 페이지에서 유일하게 상태가 있는 자리(월령 타임라인)를 여기에 둔다.
+ * 스크롤이 개월 수를 정하는 페이지에서, 여기서만은 부모가 직접 개월 수를 옮긴다.
+ */
+export function Month12(): ReactNode {
+ return (
+
+
+ 기간이 지나면
+
+ 조용히 사라집니다
+ >
+ }
+ >
+
+ 첫 돌이 지나면 첫만남이용권의 대상 기간이 끝납니다. 아무도 알려 주지 않습니다. 맘편한은
+ 아이가 지나온 개월 구간을 거슬러 올라가 대상이었던 지원금을 찾고,{' '}
+ 소급 신청이 아직 가능한 것과 이미 지난 것을 나눠 보여 드립니다.
+
+
+ 조건에 맞는 지원금은 신청 기한이 일주일 남았을 때, 그리고 하루 남았을 때 알립니다. 놓친
+ 뒤에 알려 주는 것보다 놓치기 전에 막는 편이 낫습니다.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/stations/Month18.tsx b/src/features/stations/Month18.tsx
new file mode 100644
index 0000000..03eb1dc
--- /dev/null
+++ b/src/features/stations/Month18.tsx
@@ -0,0 +1,58 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+import { MEASURED_LABEL, SOURCES, formatCount } from '@/content/figures'
+import { monthLabel } from '@/features/grow/stages'
+
+/*
+ * 18개월 — 접종과 검진이 이어지는 시기. 동네가 다시 열리고 카메라가 모퉁이 상가의 소아과로 간다.
+ * 앱에 실제로 있는 기능만 적는다(예방접종 일정 자동 생성 · 임박 알림 · 민감정보 동의).
+ */
+export function Month18(): ReactNode {
+ const clinics = SOURCES.find((s) => s.gets === '소아청소년과')
+
+ return (
+
+
+
+
{monthLabel(18)}
+
+ 접종 날짜는
+
+ 생년월일이 이미 알고 있습니다
+
+
+ 아이를 등록하면 생년월일에 맞는 표준 예방접종 일정을 자동으로 만듭니다. 생년월일을 고치면
+ 예정일도 함께 다시 계산됩니다. 접종과 검진 날짜가 다가오면 알립니다.
+
+
+ 예상 총액
+
+
+ 295,068,000원
+
+
+ 80,568,000원
+
+
+ 같은 가정, 같은 정책 목록입니다. 바뀐 것은 세는 방법뿐입니다.
+
+
+
+
+
+ 대상인지는 세 갈래로 나눕니다
+
+
+ {VERDICTS.map((v) => (
+
+
+ {v.head}
+
+
{v.body}
+
+ ))}
+
+
+ 자격 판정과 계산 방식 자세히 보기
+
+
+ )
+}
diff --git a/src/features/stations/Month36.tsx b/src/features/stations/Month36.tsx
new file mode 100644
index 0000000..21eabf6
--- /dev/null
+++ b/src/features/stations/Month36.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+import { KNOWN_UNKNOWN } from '@/content/facts'
+import { Station, StationHead } from './Station'
+
+/*
+ * 36개월 — 어린이집에서 유치원을 알아보기 시작하는 무렵.
+ * 못 하는 것을 각주가 아니라 정식 구간으로 세운다. 이 서비스가 다른 곳과 갈리는 지점이다.
+ */
+export function Month36(): ReactNode {
+ return (
+
+
+ 모르는 건
+
+ 모른다고 씁니다
+ >
+ }
+ >
+
+ 어린이집 자리와 지원금은 부모가 실제 결정을 내리는 정보입니다. 근거 없는 숫자를 그럴듯하게
+ 보여 주면, 그걸 믿고 다른 선택지를 포기한 사람이 손해를 봅니다.{' '}
+ 모른다고 쓰는 편이 낫습니다.
+
+
+
+
+ {KNOWN_UNKNOWN.map((row) => (
+
+
+
+ 아는 것
+
+
{row.known}
+
+
+
+ 모르는 것
+
+
{row.unknown}
+
{row.why}
+
+
+ ))}
+
+
+ 확실하지 않은 금액은 미상으로 둡니다. 0원으로 채우지
+ 않습니다. 데이터 출처와 한계 보기
+
+
+ )
+}
diff --git a/src/features/stations/Month48.tsx b/src/features/stations/Month48.tsx
new file mode 100644
index 0000000..9af82d1
--- /dev/null
+++ b/src/features/stations/Month48.tsx
@@ -0,0 +1,54 @@
+import { ReactNode } from 'react'
+import { Station, StationHead } from './Station'
+
+/*
+ * 48개월 — 네 해째. 이제 부모가 서비스의 데이터가 된다.
+ * 정부가 주지 않는 두 가지(실수령액 · 대기 기간)는 겪어 본 부모만 안다.
+ */
+const RULES = [
+ { v: '3건', k: '금액을 확정하는 제보 수', note: '한 사람의 오타가 금액을 흔들면 안 됩니다' },
+ { v: '3건', k: '대기 통계를 내는 입소 기록 수', note: '그보다 적으면 숫자 대신 이유를 드립니다' },
+ { v: '주 1회', k: '제보 요청 주기', note: '매일 물으면 소음이 됩니다' },
+] as const
+
+export function Month48(): ReactNode {
+ return (
+
+
+ 겪은 사람만
+
+ 아는 것
+ >
+ }
+ >
+
+ 정부는 지원금을 실제로 얼마 받았는지, 대기 순번이 언제 도는지 알려 주지 않습니다. 받아 본
+ 부모, 기다려 본 부모만 압니다. 그래서 금액 제보와 입소 기록을 직접 남기실 수 있게 했고,{' '}
+ 기록이 모이기 전에는 숫자를 만들지 않습니다.
+
+
+
+
+ {RULES.map((r) => (
+
+
{r.v}
+
{r.k}
+
{r.note}
+
+ ))}
+
+
+
+
+ “입소한 7명의 실제 기록 기준입니다. 절반이 5개월 안에 입소했습니다. 현재 12명이 대기 중으로
+ 등록해 두었습니다.”
+
+ 기록이 세 건 이상 모인 시설에서 보여 드리는 문장의 예시입니다.
+
+
+ )
+}
diff --git a/src/features/stations/Month60.tsx b/src/features/stations/Month60.tsx
new file mode 100644
index 0000000..98ae1f3
--- /dev/null
+++ b/src/features/stations/Month60.tsx
@@ -0,0 +1,52 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+import { Station, StationHead } from './Station'
+
+/*
+ * 60개월 — 다섯 해 동안 쌓인 기록. 그만큼 맡긴 정보도 많아졌다.
+ * 무엇을 받았는지보다 부모가 그걸 어떻게 되찾고 지울 수 있는지를 앞에 둔다.
+ */
+const RIGHTS = [
+ { what: '내려받기', how: '저장된 개인정보 전체를 한 번에 받아 보실 수 있습니다.' },
+ { what: '동의 관리', how: '항목별로 켜고 끌 수 있습니다. 언제 무엇에 동의했는지 이력도 남습니다.' },
+ {
+ what: '탈퇴',
+ how: '식별 정보를 지우고 계정을 닫습니다. 탈퇴한 계정으로는 로그인되지 않습니다.',
+ },
+] as const
+
+export function Month60(): ReactNode {
+ return (
+
+
+ 다섯 해치 기록은
+
+ 부모의 것입니다
+ >
+ }
+ >
+
+ 이 서비스는 아이의 진단명과 처방까지 다룹니다. 법이 민감정보로 정한 항목이고, 일반
+ 개인정보와 같은 동의로 처리할 수 없습니다. 목록에 없는 항목은 받지 않고, 쓰임이 없어진
+ 항목은 지웁니다.
+
+
+
+
+ {RIGHTS.map((r) => (
+
+
{r.what}
+
{r.how}
+
+ ))}
+
+
+ 받는 항목과 그 이유 전체 보기
+
+
+ )
+}
diff --git a/src/features/stations/Station.tsx b/src/features/stations/Station.tsx
new file mode 100644
index 0000000..0bfbd86
--- /dev/null
+++ b/src/features/stations/Station.tsx
@@ -0,0 +1,60 @@
+import { ReactNode } from 'react'
+import { monthLabel } from '@/features/grow/stages'
+
+/**
+ * 구간 하나. 스크롤 엔진(GrowScene)은 data-month 로 개월 수의 닻을, data-open 으로
+ * "이 구간에서는 뒤의 동네가 보인다"를 읽는다.
+ *
+ * open 구간은 지면을 비우고 글을 종이 판(yr-panel) 위에 올린다. 풍경 위에 글을 바로
+ * 얹으면 계절마다 바탕색이 바뀌어 대비가 보장되지 않는다.
+ */
+export function Station({
+ id,
+ month,
+ open = false,
+ children,
+}: {
+ id: string
+ month: number
+ open?: boolean
+ children: ReactNode
+}): ReactNode {
+ return (
+
+
{children}
+
+ )
+}
+
+/** 구간 머리. 도장(개월 수) · 제목 · 본문. 제목이 스스로 서도록 눈썹은 두지 않는다. */
+export function StationHead({
+ id,
+ month,
+ title,
+ children,
+}: {
+ id: string
+ month: number
+ title: ReactNode
+ children: ReactNode
+}): ReactNode {
+ return (
+
+
+
{monthLabel(month)}
+
+ {title}
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/features/town/scene-util.ts b/src/features/town/scene-util.ts
new file mode 100644
index 0000000..8b92642
--- /dev/null
+++ b/src/features/town/scene-util.ts
@@ -0,0 +1,166 @@
+/* 동네 장면 공용 유틸 — three.js 장면이 필요로 하는 최소한만 둔다. */
+import * as THREE from 'three'
+
+/* 2D 컨텍스트는 이론상 null 이 될 수 있다. 여기서 한 번만 좁힌다. */
+export function ctx2d(c: HTMLCanvasElement): CanvasRenderingContext2D {
+ const g = c.getContext('2d')
+ if (!g) throw new Error('2D 컨텍스트를 만들 수 없습니다')
+ return g
+}
+
+export function makeCanvas(host: HTMLElement): HTMLCanvasElement {
+ const c = document.createElement('canvas')
+ c.style.display = 'block'
+ c.style.width = '100%'
+ c.style.height = '100%'
+ host.appendChild(c)
+ return c
+}
+
+export function sizeToHost(
+ host: HTMLElement,
+ renderer: THREE.WebGLRenderer,
+ camera: THREE.PerspectiveCamera,
+): { w: number; h: number } {
+ const r = host.getBoundingClientRect()
+ const w = Math.max(1, Math.round(r.width))
+ const h = Math.max(1, Math.round(r.height))
+ renderer.setSize(w, h, false)
+ camera.aspect = w / h
+ camera.updateProjectionMatrix()
+ return { w, h }
+}
+
+export function observeResize(host: HTMLElement, fn: () => void): () => void {
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', fn)
+ return () => window.removeEventListener('resize', fn)
+ }
+ const ro = new ResizeObserver(fn)
+ ro.observe(host)
+ return () => ro.disconnect()
+}
+
+/*
+ * 화면 밖이거나 탭이 숨겨졌으면 루프를 멈춘다.
+ * 고정 캔버스라 불투명 구간 뒤에 가려져 있는 동안은 그릴 이유가 없다.
+ */
+export type LoopGate = {
+ setAllowed(v: boolean): void
+ dispose(): void
+}
+
+export function createLoopGate(onFrame: (dt: number) => void): LoopGate {
+ let raf = 0
+ let running = false
+ let allowed = true
+ const timer = new THREE.Timer()
+
+ function tick(now: number) {
+ raf = requestAnimationFrame(tick)
+ timer.update(now)
+ onFrame(Math.min(timer.getDelta(), 0.05))
+ }
+
+ function sync() {
+ const should = allowed && !document.hidden
+ if (should && !running) {
+ running = true
+ timer.reset()
+ raf = requestAnimationFrame(tick)
+ } else if (!should && running) {
+ running = false
+ cancelAnimationFrame(raf)
+ }
+ }
+
+ document.addEventListener('visibilitychange', sync)
+ sync()
+
+ return {
+ setAllowed(v: boolean) {
+ allowed = v
+ sync()
+ },
+ dispose() {
+ allowed = false
+ sync()
+ document.removeEventListener('visibilitychange', sync)
+ },
+ }
+}
+
+export function disposeObject3D(root: THREE.Object3D): void {
+ root.traverse((o) => {
+ const mesh = o as THREE.Mesh
+ if (mesh.geometry) mesh.geometry.dispose()
+ const m = mesh.material
+ if (!m) return
+ const list: THREE.Material[] = Array.isArray(m) ? m : [m]
+ list.forEach((mat) => {
+ for (const k in mat) {
+ const v = (mat as unknown as Record)[k] as THREE.Texture | undefined
+ if (v && v.isTexture) v.dispose()
+ }
+ mat.dispose()
+ })
+ })
+}
+
+/*
+ * 창문 텍스처. 벽을 상자 하나로 두고 창을 그림으로 얹는다 — 창마다 메시를 만들면
+ * 건물 하나에 수백 번의 드로우콜이 든다.
+ * 한 장이 한 층·창 네 칸이다. repeat 로 층수와 폭을 맞춘다.
+ */
+export function windowTexture(wall: string, glass: string, frame: string): THREE.CanvasTexture {
+ const c = document.createElement('canvas')
+ c.width = 256
+ c.height = 128
+ const g = ctx2d(c)
+ g.fillStyle = wall
+ g.fillRect(0, 0, 256, 128)
+ for (let i = 0; i < 4; i++) {
+ const x = 14 + i * 62
+ g.fillStyle = frame
+ g.fillRect(x - 3, 30, 42, 64)
+ g.fillStyle = glass
+ g.fillRect(x, 33, 36, 58)
+ /* 유리 위쪽을 조금 밝게 — 하늘이 비친 것처럼 읽힌다 */
+ g.fillStyle = 'rgba(255,255,255,0.18)'
+ g.fillRect(x, 33, 36, 16)
+ }
+ /* 층 사이 띠 */
+ g.fillStyle = 'rgba(0,0,0,0.06)'
+ g.fillRect(0, 122, 256, 6)
+ const t = new THREE.CanvasTexture(c)
+ t.wrapS = t.wrapT = THREE.RepeatWrapping
+ t.colorSpace = THREE.SRGBColorSpace
+ t.anisotropy = 4
+ return t
+}
+
+/* 아스팔트·보도처럼 면이 균일하면 CG 로 읽히는 표면에 얹을 잡티 */
+export function grainTexture(size: number, base: string, spec: string, amount: number): THREE.CanvasTexture {
+ const c = document.createElement('canvas')
+ c.width = c.height = size
+ const g = ctx2d(c)
+ g.fillStyle = base
+ g.fillRect(0, 0, size, size)
+ for (let i = 0; i < size * size * amount; i++) {
+ g.fillStyle = spec
+ g.globalAlpha = 0.05 + Math.random() * 0.16
+ g.fillRect(Math.random() * size, Math.random() * size, 1, 1)
+ }
+ g.globalAlpha = 1
+ const t = new THREE.CanvasTexture(c)
+ t.wrapS = t.wrapT = THREE.RepeatWrapping
+ t.colorSpace = THREE.SRGBColorSpace
+ return t
+}
+
+export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t
+
+export const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v)
+
+/* 키프레임 사이를 선형으로 이으면 경계에서 뻣뻣하다. 살짝 부드럽게 */
+export const smooth = (k: number): number => k * k * (3 - 2 * k)
diff --git a/src/features/town/town-scene.ts b/src/features/town/town-scene.ts
new file mode 100644
index 0000000..5aa6df2
--- /dev/null
+++ b/src/features/town/town-scene.ts
@@ -0,0 +1,968 @@
+/*
+ * 동네 한 블록 — 아이가 자라는 여섯 해.
+ *
+ * 이 캔버스는 홈 전체 뒤에 고정되어 있고, 스크롤 진행이 곧 아이의 개월 수다(0 → 72).
+ * 개월 수 하나가 세 가지를 동시에 정한다.
+ * 1) 계절 — 4월생으로 가정한다. 벚꽃, 초록, 은행잎, 눈이 차례로 지나간다
+ * 2) 성장 — 유모차에서 걸음마로, 마지막에는 책가방을 멘다. 태어난 해에 심은 나무도 함께 자란다
+ * 3) 시선 — 그 시기에 부모가 드나드는 건물(어린이집·소아과·행정복지센터)로 카메라가 간다
+ *
+ * 스케일 규약: 1 unit = 1 m. 어른 1.68 m 를 기준으로 전부 잡았다.
+ * 빌라 층고 2.9 · 상가 층고 3.4 · 도로 폭 8 · 보도 폭 2.5 · 은행나무 6~7H
+ * 이 규약이 없으면 블록 크기가 감으로 정해져 장난감처럼 읽힌다.
+ *
+ * 카피는 전부 DOM 이다. 캔버스에 글자를 그리지 않으므로 SEO·스크린리더에 손실이 없고,
+ * 간판도 색 면으로만 둔다.
+ *
+ * WebGL2·동작 줄이기 판정은 여기가 아니라 GrowScene 이 import 전에 한다 —
+ * 쓰지 않을 사용자에게 three.js 청크를 받게 하지 않기 위해서다.
+ */
+import * as THREE from 'three'
+import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
+import type { TownHandle } from './types'
+import {
+ clamp01,
+ createLoopGate,
+ disposeObject3D,
+ grainTexture,
+ lerp,
+ makeCanvas,
+ observeResize,
+ sizeToHost,
+ smooth,
+ windowTexture,
+} from './scene-util'
+
+const ADULT = 1.68
+const MAX_MONTH = 72
+
+/* 달력 월(0 = 1월). 4월생이면 0·12·…·72개월이 전부 봄이고, 6·18개월은 가을이 된다.
+ 동네가 열리는 구간(출생 · 6 · 18 · 72개월)이 봄과 가을에 앉도록 고른 값이다. */
+const BIRTH_CAL_MONTH = 3
+
+/* ------------------------------------------------------------------
+ 계절 키프레임. at 은 달력 월(0 = 1월 초, 12 = 다음 해 1월 초).
+ ------------------------------------------------------------------ */
+type Season = {
+ at: number
+ sky: number
+ grass: number
+ /** 은행나무 잎 */
+ ginkgo: number
+ /** 벚나무 — 봄에는 꽃, 여름에는 잎, 가을에는 단풍 */
+ cherry: number
+ /** 잎의 양. 0 이면 가지만 남는다 */
+ canopy: number
+ /** 쌓인 눈 */
+ snow: number
+ /** 태양 고도(rad) */
+ sunEl: number
+ sunI: number
+ sun: number
+ ambI: number
+}
+
+const SEASONS: Season[] = [
+ { at: 0.5, sky: 0xdfe6ec, grass: 0xb3b8a6, ginkgo: 0x8d7b5a, cherry: 0x8d7b5a, canopy: 0.0, snow: 0.92, sunEl: 0.5, sunI: 2.3, sun: 0xfff0dc, ambI: 1.0 },
+ { at: 3.4, sky: 0xe3edf1, grass: 0x9dc37c, ginkgo: 0xa9cf6e, cherry: 0xf4c3d3, canopy: 1.0, snow: 0.0, sunEl: 0.85, sunI: 2.7, sun: 0xfff6e8, ambI: 0.85 },
+ { at: 6.6, sky: 0xd5e8f2, grass: 0x6aa64e, ginkgo: 0x4f9437, cherry: 0x4c8f35, canopy: 1.08, snow: 0.0, sunEl: 1.05, sunI: 2.95, sun: 0xfffaf0, ambI: 0.8 },
+ { at: 9.6, sky: 0xece5d8, grass: 0xb1ad76, ginkgo: 0xf1c230, cherry: 0xd2683a, canopy: 0.95, snow: 0.0, sunEl: 0.72, sunI: 2.6, sun: 0xffe7c4, ambI: 0.85 },
+ { at: 11.3, sky: 0xe2e3e0, grass: 0xa9ab8c, ginkgo: 0xd8b24a, cherry: 0xa4643e, canopy: 0.25, snow: 0.2, sunEl: 0.55, sunI: 2.4, sun: 0xfff0dc, ambI: 0.95 },
+]
+
+function seasonAt(m: number): { a: Season; b: Season; k: number } {
+ let cal = (((BIRTH_CAL_MONTH + m) % 12) + 12) % 12
+ const first = SEASONS[0]
+ const wrap = { ...first, at: first.at + 12 }
+ const keys = [...SEASONS, wrap]
+ if (cal < first.at) cal += 12
+ for (let i = 0; i < keys.length - 1; i++) {
+ const a = keys[i]
+ const b = keys[i + 1]
+ if (cal >= a.at && cal <= b.at) return { a, b, k: smooth((cal - a.at) / (b.at - a.at)) }
+ }
+ return { a: first, b: first, k: 0 }
+}
+
+/* ------------------------------------------------------------------
+ 개월 수 키프레임 — 가족이 서 있는 곳과 카메라가 보는 곳.
+ 구간 사이에서 smoothstep 으로 이으므로, 도장에 찍힌 개월 수에서 가족이 "도착"한다.
+ ------------------------------------------------------------------ */
+type Place = 'home' | 'daycare' | 'tree' | 'clinic' | 'center' | 'town'
+
+type Beat = { m: number; at: [number, number]; look: [number, number]; dist: number; focus: Place }
+
+/* 좌표는 아래 배치(PLACES)와 같은 동네 지도를 본다. 가족은 보도(±4.9) 위를 걷는다. */
+const BEATS: Beat[] = [
+ { m: 0, at: [-26, -5.2], look: [-20, -8], dist: 92, focus: 'home' },
+ { m: 6, at: [15, -5.2], look: [13, -10], dist: 74, focus: 'daycare' },
+ { m: 12, at: [11.5, 11.5], look: [13, 12], dist: 70, focus: 'tree' },
+ { m: 18, at: [-11.5, -5.2], look: [-10, -9], dist: 72, focus: 'clinic' },
+ { m: 24, at: [-5.2, 14], look: [-11, 12], dist: 76, focus: 'center' },
+ { m: 36, at: [17, -5.2], look: [13, -10], dist: 80, focus: 'daycare' },
+ { m: 48, at: [18, 12], look: [9, 8], dist: 90, focus: 'tree' },
+ { m: 60, at: [5.2, 9], look: [0, 0], dist: 104, focus: 'town' },
+ { m: 72, at: [-24, -5.2], look: [-4, -2], dist: 122, focus: 'town' },
+]
+
+function beatAt(m: number): { a: Beat; b: Beat; k: number } {
+ if (m <= BEATS[0].m) return { a: BEATS[0], b: BEATS[0], k: 0 }
+ const last = BEATS[BEATS.length - 1]
+ if (m >= last.m) return { a: last, b: last, k: 0 }
+ for (let i = 0; i < BEATS.length - 1; i++) {
+ const a = BEATS[i]
+ const b = BEATS[i + 1]
+ if (m >= a.m && m <= b.m) return { a, b, k: smooth((m - a.m) / (b.m - a.m)) }
+ }
+ return { a: last, b: last, k: 0 }
+}
+
+/* 아이 키(m). 0개월 0.5 m 에서 72개월 1.16 m. 초기에 빨리 크는 모양을 제곱근으로 흉내 낸다.
+ 화면에 숫자로 내보내는 값이 아니라 크기 비례만 맞춘 것이다. */
+const childHeight = (m: number): number => 0.5 + 0.66 * Math.sqrt(clamp01(m / MAX_MONTH))
+
+/* ------------------------------------------------------------------
+ 동네 지도. 도로 두 개가 가운데서 만난다(x 축 도로 z=0, z 축 도로 x=0, 폭 8).
+ 카메라가 +x+z 쪽에서 내려다보므로 건물의 정면은 +z 또는 +x 를 향해야 보인다.
+ 그래서 네 블록 중 가장 가까운 남동쪽(+x+z)은 낮은 공원으로 비워 둔다.
+ ------------------------------------------------------------------ */
+const PLACES = {
+ home: { x: -26, z: -12, w: 12, d: 10 },
+ clinic: { x: -11.5, z: -11.5, w: 9, d: 9 },
+ daycare: { x: 15, z: -14, w: 14, d: 9 },
+ center: { x: -14, z: 14, w: 12, d: 12 },
+ park: { x: 15, z: 15 },
+} as const
+
+export function mount(host: HTMLElement): TownHandle {
+ const canvas = makeCanvas(host)
+ const W0 = host.getBoundingClientRect().width
+ const TIER = W0 >= 1280 ? 'high' : W0 >= 900 ? 'mid' : 'low'
+
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' })
+ renderer.setPixelRatio(Math.min(devicePixelRatio, TIER === 'high' ? 2 : 1.5))
+ renderer.shadowMap.enabled = true
+ /* r18x 부터 PCFSoftShadowMap 은 없어졌고 PCFShadowMap 이 부드러운 쪽을 맡는다 */
+ renderer.shadowMap.type = THREE.PCFShadowMap
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
+ /* 종이색 지면 위에 앉는 장면이라 한낮에도 하얗게 뜨지 않게 조금 누른다 */
+ renderer.toneMappingExposure = 0.96
+
+ const scene = new THREE.Scene()
+ const skyColor = new THREE.Color(SEASONS[1].sky)
+ scene.background = skyColor
+ const fog = new THREE.Fog(SEASONS[1].sky, 150, 330)
+ scene.fog = fog
+
+ /* ---------------- 카메라 ----------------
+ 망원 원근(fov 24)이 아이소메트릭 느낌을 지키면서 사람이 사람 크기로 읽힌다.
+ 순수 직교는 도면처럼 납작해진다. */
+ const camera = new THREE.PerspectiveCamera(24, 1, 8, 520)
+ const UP = new THREE.Vector3(0, 1, 0)
+ const CAM_DIR = new THREE.Vector3(0.62, 0.72, 0.86).normalize()
+ /* 카피가 왼쪽에 앉는 넓은 화면에서는 피사체를 오른쪽으로 민다(거리에 대한 비율) */
+ let panRatio = 0
+ /* 좁은 화면은 종이 판이 아래쪽을 덮으므로 피사체를 위로 올린다(화면 높이에 대한 비율) */
+ let liftRatio = 0
+ /* 세로로 긴 화면은 가로 시야가 좁아 같은 거리에서 건물만 꽉 찬다. 거리를 늘려 동네 폭을 맞춘다 */
+ let distScale = 1
+ const HALF_FOV = THREE.MathUtils.degToRad(12)
+ const camDir = new THREE.Vector3()
+ const camRight = new THREE.Vector3()
+ const camUp = new THREE.Vector3()
+ const camAt = new THREE.Vector3()
+
+ function placeCamera(look: THREE.Vector3, baseDist: number, orbit: number) {
+ const dist = baseDist * distScale
+ camDir.copy(CAM_DIR).applyAxisAngle(UP, orbit)
+ camRight.crossVectors(UP, camDir).normalize()
+ camUp.crossVectors(camDir, camRight).normalize()
+ /* 보는 점을 왼쪽·아래로 옮기면 피사체가 화면 오른쪽·위에 앉는다 */
+ camAt
+ .copy(look)
+ .addScaledVector(camRight, -dist * panRatio)
+ .addScaledVector(camUp, -2 * dist * Math.tan(HALF_FOV) * liftRatio)
+ camera.position.copy(camAt).addScaledVector(camDir, dist)
+ camera.lookAt(camAt)
+ }
+
+ /* ---------------- 조명 ---------------- */
+ const hemi = new THREE.HemisphereLight(0xdfeaf2, 0x8a8a70, 0.85)
+ scene.add(hemi)
+
+ const sun = new THREE.DirectionalLight(0xfff6e8, 2.7)
+ sun.castShadow = true
+ const SM = TIER === 'high' ? 2048 : TIER === 'mid' ? 1536 : 1024
+ sun.shadow.mapSize.set(SM, SM)
+ const S = 58
+ sun.shadow.camera.left = -S
+ sun.shadow.camera.right = S
+ sun.shadow.camera.top = S
+ sun.shadow.camera.bottom = -S
+ sun.shadow.camera.near = 20
+ sun.shadow.camera.far = 260
+ sun.shadow.bias = -0.0008
+ sun.shadow.normalBias = 0.04
+ /* 그림자는 매 프레임 다시 구울 이유가 없다. 태양은 계절이 바뀔 때만 움직이고,
+ 움직이는 것은 사람과 차 정도다. 자동 갱신을 끄고 필요할 때만 올린다. */
+ sun.shadow.autoUpdate = false
+ sun.shadow.needsUpdate = true
+ sun.target.position.set(0, 0, 0)
+ scene.add(sun, sun.target)
+
+ const world = new THREE.Group()
+ scene.add(world)
+
+ /* ================================================================
+ 지면 · 도로 · 보도
+ ================================================================ */
+ const grassMat = new THREE.MeshStandardMaterial({ color: SEASONS[1].grass, roughness: 1 })
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(220, 220), grassMat)
+ ground.rotation.x = -Math.PI / 2
+ ground.receiveShadow = true
+ world.add(ground)
+
+ const asphaltTex = grainTexture(256, '#62666b', '#2a2d30', 0.4)
+ asphaltTex.repeat.set(30, 2)
+ const asphaltMat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: asphaltTex, roughness: 0.95 })
+ const asphaltBase = new THREE.Color(0xffffff)
+
+ const roadX = new THREE.Mesh(new THREE.PlaneGeometry(220, 8), asphaltMat)
+ roadX.rotation.x = -Math.PI / 2
+ roadX.position.y = 0.02
+ roadX.receiveShadow = true
+ const roadZ = new THREE.Mesh(new THREE.PlaneGeometry(8, 220), asphaltMat)
+ roadZ.rotation.x = -Math.PI / 2
+ roadZ.position.y = 0.021
+ roadZ.receiveShadow = true
+ world.add(roadX, roadZ)
+
+ const walkTex = grainTexture(128, '#d2d5cd', '#9aa096', 0.3)
+ walkTex.repeat.set(20, 1)
+ const walkMat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: walkTex, roughness: 0.95 })
+ const walkBase = new THREE.Color(0xffffff)
+ /* 보도는 교차로에서 끊긴다. 네 방향 × 양쪽 = 여덟 조각 */
+ const WALK_LEN = 100
+ for (const side of [-1, 1]) {
+ for (const half of [-1, 1]) {
+ const cx = half * (4 + WALK_LEN / 2)
+ const a = new THREE.Mesh(new THREE.BoxGeometry(WALK_LEN, 0.16, 2.5), walkMat)
+ a.position.set(cx, 0.08, side * 5.25)
+ const b = new THREE.Mesh(new THREE.BoxGeometry(2.5, 0.16, WALK_LEN), walkMat)
+ b.position.set(side * 5.25, 0.08, cx)
+ a.receiveShadow = b.receiveShadow = true
+ world.add(a, b)
+ }
+ }
+
+ /* 중앙선(노란 겹선)과 횡단보도. 막대 하나하나를 메시로 만들면 드로우콜이 수십 개라 인스턴스로 */
+ const lineGeo = new THREE.BoxGeometry(1, 0.02, 1)
+ const yellowMat = new THREE.MeshStandardMaterial({ color: 0xe6c24a, roughness: 0.7 })
+ const whiteMat = new THREE.MeshStandardMaterial({ color: 0xf1f2ed, roughness: 0.7 })
+ const tmpM = new THREE.Matrix4()
+ const tmpQ = new THREE.Quaternion()
+ const tmpP = new THREE.Vector3()
+ const tmpS = new THREE.Vector3()
+
+ {
+ const centers: [number, number, number, number][] = [] // x, z, sx, sz
+ for (const half of [-1, 1]) {
+ for (const off of [-0.12, 0.12]) {
+ centers.push([half * 57, off, 100, 0.12])
+ centers.push([off, half * 57, 0.12, 100])
+ }
+ }
+ const yellow = new THREE.InstancedMesh(lineGeo, yellowMat, centers.length)
+ centers.forEach(([x, z, sx, sz], i) => {
+ tmpM.compose(tmpP.set(x, 0.035, z), tmpQ.identity(), tmpS.set(sx, 1, sz))
+ yellow.setMatrixAt(i, tmpM)
+ })
+ world.add(yellow)
+
+ const stripes: [number, number, number, number][] = []
+ for (const side of [-1, 1]) {
+ for (let s = -3.25; s <= 3.3; s += 1.08) {
+ stripes.push([side * 8.2, s, 3.0, 0.5]) // x 축 도로를 건너는 횡단보도
+ stripes.push([s, side * 8.2, 0.5, 3.0]) // z 축 도로를 건너는 횡단보도
+ }
+ }
+ const zebra = new THREE.InstancedMesh(lineGeo, whiteMat, stripes.length)
+ stripes.forEach(([x, z, sx, sz], i) => {
+ tmpM.compose(tmpP.set(x, 0.035, z), tmpQ.identity(), tmpS.set(sx, 1, sz))
+ zebra.setMatrixAt(i, tmpM)
+ })
+ zebra.receiveShadow = true
+ world.add(zebra)
+ }
+
+ /* ================================================================
+ 건물 — 벽은 상자 하나, 창은 텍스처. 옥상은 공용 재질이라 눈이 한 번에 쌓인다.
+ ================================================================ */
+ const roofMat = new THREE.MeshStandardMaterial({ color: 0xb8b3a8, roughness: 0.95 })
+ const roofBase = new THREE.Color(0xb8b3a8)
+
+ type Spec = {
+ x: number
+ z: number
+ w: number
+ d: number
+ floors: number
+ floorH: number
+ wall: string
+ glass?: string
+ frame?: string
+ trim?: number
+ /** 1층을 상가 유리로 */
+ shopfront?: string
+ }
+
+ /*
+ * 벽 상자. BoxGeometry 는 면마다 그룹이 따로라 재질 배열을 주면 상자 하나에 드로우콜이
+ * 여섯 번 든다. 창 반복은 텍스처 repeat 대신 면별 UV 로 넣고, 인덱스를 [옆면 넷 | 윗·아랫면]
+ * 순서로 다시 묶어 재질 두 개(벽 · 옥상)로 그린다.
+ * 면 순서는 +x, -x, +y, -y, +z, -z 이고 면마다 정점 4개 · 인덱스 6개다.
+ */
+ function wallBox(w: number, h: number, d: number, floors: number): THREE.BoxGeometry {
+ const geo = new THREE.BoxGeometry(w, h, d)
+ const uv = geo.attributes.uv
+ const across = [d, d, w, w, w, w] // 면의 가로 길이(m)
+ for (let face = 0; face < 6; face++) {
+ const ru = Math.max(1, Math.round(across[face] / 9))
+ const rv = face === 2 || face === 3 ? 1 : floors
+ for (let v = face * 4; v < face * 4 + 4; v++) uv.setXY(v, uv.getX(v) * ru, uv.getY(v) * rv)
+ }
+ const idx = Array.from(geo.index!.array)
+ const face = (f: number) => idx.slice(f * 6, f * 6 + 6)
+ geo.setIndex([...face(0), ...face(1), ...face(4), ...face(5), ...face(2), ...face(3)])
+ geo.clearGroups()
+ geo.addGroup(0, 24, 0)
+ geo.addGroup(24, 12, 1)
+ return geo
+ }
+
+ const wallMats = new Map()
+ function wallMaterial(wall: string, glass: string, frame: string): THREE.MeshStandardMaterial {
+ const key = `${wall}|${glass}|${frame}`
+ let m = wallMats.get(key)
+ if (!m) {
+ m = new THREE.MeshStandardMaterial({ map: windowTexture(wall, glass, frame), roughness: 0.9 })
+ wallMats.set(key, m)
+ }
+ return m
+ }
+
+ function building(s: Spec): THREE.Group {
+ const g = new THREE.Group()
+ const base = s.shopfront ? s.floorH : 0
+ const upper = s.shopfront ? s.floors - 1 : s.floors
+ const walls = wallMaterial(s.wall, s.glass ?? '#7f95a6', s.frame ?? '#cfc6b4')
+
+ if (upper > 0) {
+ const h = upper * s.floorH
+ const body = new THREE.Mesh(wallBox(s.w, h, s.d, upper), [walls, roofMat])
+ body.position.set(s.x, base + h / 2, s.z)
+ body.castShadow = body.receiveShadow = true
+ g.add(body)
+ }
+
+ if (s.shopfront) {
+ const shop = wallMaterial(s.shopfront, '#3f5868', '#2f3b44')
+ const storefront = new THREE.Mesh(wallBox(s.w - 0.3, s.floorH, s.d - 0.3, 1), [shop, roofMat])
+ storefront.position.set(s.x, s.floorH / 2, s.z)
+ storefront.castShadow = storefront.receiveShadow = true
+ g.add(storefront)
+ }
+
+ /* 난간. 윗면은 옥상 재질이라 눈이 같이 쌓인다 */
+ const top = s.floors * s.floorH
+ const trimMat = new THREE.MeshStandardMaterial({ color: s.trim ?? new THREE.Color(s.wall).multiplyScalar(0.9), roughness: 0.9 })
+ const parapet = new THREE.Mesh(wallBox(s.w + 0.3, 0.55, s.d + 0.3, 1), [trimMat, roofMat])
+ parapet.position.set(s.x, top + 0.27, s.z)
+ parapet.castShadow = true
+ g.add(parapet)
+
+ /* 옥상 설비 한 덩이 — 평평한 옥상은 비어 있으면 모형처럼 보인다 */
+ const box = new THREE.Mesh(new THREE.BoxGeometry(Math.min(3, s.w * 0.25), 1.2, 2), roofMat)
+ box.position.set(s.x - s.w * 0.22, top + 1.1, s.z - s.d * 0.15)
+ box.castShadow = true
+ g.add(box)
+
+ world.add(g)
+ return g
+ }
+
+ /* 간판. 글자는 그리지 않는다 — 색 면이 무엇의 간판인지는 DOM 카피가 말한다 */
+ function sign(x: number, y: number, z: number, w: number, h: number, color: number, faceX = false) {
+ const m = new THREE.Mesh(
+ new THREE.BoxGeometry(faceX ? 0.18 : w, h, faceX ? w : 0.18),
+ new THREE.MeshStandardMaterial({ color, roughness: 0.6 }),
+ )
+ m.position.set(x, y, z)
+ m.castShadow = true
+ world.add(m)
+ }
+
+ const SEAL = 0x36aa1c
+ const STAMP = 0xc2453d
+
+ // 집 — 4층 빌라. 정면(+z)이 보도를 본다
+ const P = PLACES
+ building({ ...P.home, floors: 4, floorH: 2.9, wall: '#ebe2d2', frame: '#cdbfa6' })
+ sign(P.home.x, 1.1, P.home.z + P.home.d / 2 + 0.1, 2.2, 2.2, 0x8a7d66) // 현관문
+ {
+ const canopy = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.14, 1.4), roofMat)
+ canopy.position.set(P.home.x, 2.3, P.home.z + P.home.d / 2 + 0.7)
+ canopy.castShadow = true
+ world.add(canopy)
+ }
+
+ // 소아청소년과가 든 모퉁이 상가 — 1층 유리, 2층 간판
+ building({ ...P.clinic, floors: 3, floorH: 3.4, wall: '#f1efe9', frame: '#c9ccc6', shopfront: '#e7e9e4' })
+ sign(P.clinic.x, 4.6, P.clinic.z + P.clinic.d / 2 + 0.1, 6.5, 1.1, 0xffffff)
+ sign(P.clinic.x - 2.6, 4.6, P.clinic.z + P.clinic.d / 2 + 0.22, 1.0, 0.8, SEAL)
+ sign(P.clinic.x + P.clinic.w / 2 + 0.1, 4.6, P.clinic.z, 5, 1.1, 0xffffff, true)
+
+ // 어린이집 — 2층, 초록 띠, 앞마당과 울타리
+ building({ ...P.daycare, floors: 2, floorH: 3.2, wall: '#f6f0e3', frame: '#e3cf9c', trim: SEAL })
+ sign(P.daycare.x - 3, 5.2, P.daycare.z + P.daycare.d / 2 + 0.1, 4.2, 0.9, 0xf2b93c)
+ {
+ const yardZ0 = P.daycare.z + P.daycare.d / 2 // 건물 앞면
+ const yardZ1 = -6.8 // 울타리
+ const x0 = P.daycare.x - P.daycare.w / 2
+ const x1 = P.daycare.x + P.daycare.w / 2
+ const posts: [number, number][] = []
+ for (let x = x0; x <= x1 + 0.01; x += 1.2) if (Math.abs(x - P.daycare.x) > 1.1) posts.push([x, yardZ1])
+ for (let z = yardZ0; z <= yardZ1; z += 1.2) {
+ posts.push([x0, z])
+ posts.push([x1, z])
+ }
+ const postMat = new THREE.MeshStandardMaterial({ color: 0xf4f1e8, roughness: 0.8 })
+ const fence = new THREE.InstancedMesh(new THREE.BoxGeometry(0.1, 1.0, 0.1), postMat, posts.length)
+ posts.forEach(([x, z], i) => {
+ tmpM.compose(tmpP.set(x, 0.5, z), tmpQ.identity(), tmpS.set(1, 1, 1))
+ fence.setMatrixAt(i, tmpM)
+ })
+ fence.castShadow = true
+ world.add(fence)
+ const rail = new THREE.Mesh(new THREE.BoxGeometry(P.daycare.w, 0.08, 0.08), postMat)
+ rail.position.set(P.daycare.x, 0.95, yardZ1)
+ world.add(rail)
+
+ /* 미끄럼틀 — 노랑 발판, 빨강 활주면 */
+ const slideBase = new THREE.Mesh(new RoundedBoxGeometry(1.2, 1.4, 1.2, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0xf2b93c, roughness: 0.6 }))
+ slideBase.position.set(P.daycare.x + 3.8, 0.7, yardZ0 + 1.2)
+ const slide = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.1, 2.2), new THREE.MeshStandardMaterial({ color: STAMP, roughness: 0.5 }))
+ slide.position.set(P.daycare.x + 3.8, 0.75, yardZ0 + 2.5)
+ slide.rotation.x = 0.55
+ const sandbox = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.25, 1.4), new THREE.MeshStandardMaterial({ color: 0xe0cfa6, roughness: 1 }))
+ sandbox.position.set(P.daycare.x - 3.5, 0.12, yardZ0 + 1.4)
+ for (const m of [slideBase, slide, sandbox]) {
+ m.castShadow = m.receiveShadow = true
+ world.add(m)
+ }
+ }
+
+ // 행정복지센터 — 정면(+x)이 z 축 도로를 본다. 국기 게양대
+ building({ ...P.center, floors: 2, floorH: 3.6, wall: '#e2ddd2', glass: '#6f8796', frame: '#b8b1a2', trim: 0x9f988a })
+ sign(P.center.x + P.center.w / 2 + 0.1, 5.6, P.center.z, 5.5, 0.9, 0x2f5f8f, true)
+ {
+ const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 8.5, 8), new THREE.MeshStandardMaterial({ color: 0xd7d9d6, metalness: 0.4, roughness: 0.4 }))
+ pole.position.set(-7.2, 4.25, 8.4)
+ pole.castShadow = true
+ const flag = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 1.0), new THREE.MeshStandardMaterial({ color: 0xf6f6f2, side: THREE.DoubleSide, roughness: 0.8 }))
+ flag.position.set(-6.45, 7.9, 8.4)
+ flag.castShadow = true
+ world.add(pole, flag)
+ }
+
+ // 배경 아파트와 상가 — 뒤쪽(-z)과 왼쪽(-x)에만 둔다. 앞에 두면 주인공 건물을 가린다
+ const FILLER: Spec[] = [
+ { x: -30, z: -36, w: 16, d: 10, floors: 12, floorH: 2.9, wall: '#e6e4df' },
+ { x: -6, z: -38, w: 12, d: 10, floors: 9, floorH: 2.9, wall: '#dcdfdb' },
+ { x: 16, z: -36, w: 14, d: 10, floors: 14, floorH: 2.9, wall: '#ece7dc' },
+ { x: 40, z: -32, w: 12, d: 10, floors: 8, floorH: 2.9, wall: '#e2e4e0' },
+ { x: 40, z: -14, w: 10, d: 10, floors: 4, floorH: 3.1, wall: '#efe8dc', shopfront: '#e4e1d8' },
+ { x: -48, z: -12, w: 10, d: 12, floors: 5, floorH: 2.9, wall: '#e9e3d6' },
+ { x: -48, z: 16, w: 10, d: 12, floors: 4, floorH: 3.0, wall: '#dfe1dc' },
+ { x: -28, z: 32, w: 12, d: 10, floors: 3, floorH: 3.2, wall: '#ece6da', shopfront: '#e2dfd6' },
+ ]
+ FILLER.forEach((s) => building(s))
+
+ /* ================================================================
+ 나무 — 은행나무 가로수, 공원의 벚나무, 그리고 태어난 해에 심은 한 그루
+ ================================================================ */
+ const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b5a48, roughness: 1 })
+ const ginkgoMat = new THREE.MeshStandardMaterial({ color: SEASONS[1].ginkgo, roughness: 0.9 })
+ const cherryMat = new THREE.MeshStandardMaterial({ color: SEASONS[1].cherry, roughness: 0.9 })
+
+ type Tree = { x: number; z: number; h: number; r: number }
+
+ const ginkgoSpots: Tree[] = []
+ for (const side of [-1, 1]) {
+ for (let d = 10; d <= 46; d += 6) {
+ for (const half of [-1, 1]) {
+ const jitter = ((d * 7 + side * 3 + half) % 5) * 0.12
+ ginkgoSpots.push({ x: half * d, z: side * 6.1, h: 6.2 + jitter, r: 1.5 })
+ ginkgoSpots.push({ x: side * 6.1, z: half * d, h: 6.0 + jitter, r: 1.45 })
+ }
+ }
+ }
+
+ const cherrySpots: Tree[] = []
+ for (let i = 0; i < 8; i++) {
+ const a = (i / 8) * Math.PI * 2 + 0.3
+ cherrySpots.push({ x: P.park.x + Math.cos(a) * 9.5, z: P.park.z + Math.sin(a) * 9.5, h: 4.6, r: 2.0 })
+ }
+ cherrySpots.push({ x: -21, z: 23, h: 4.4, r: 1.9 }, { x: -24, z: 8.5, h: 4.2, r: 1.8 })
+
+ const trunkGeo = new THREE.CylinderGeometry(0.16, 0.24, 1, 7)
+ trunkGeo.translate(0, 0.5, 0)
+ /* 은행나무는 원뿔, 벚나무는 넓게 퍼진 구 — 실루엣만으로 종이 갈린다 */
+ const ginkgoGeo = new THREE.ConeGeometry(1, 1, 9)
+ ginkgoGeo.translate(0, 0.5, 0)
+ const cherryGeo = new THREE.IcosahedronGeometry(1, 1)
+ cherryGeo.scale(1, 0.72, 1)
+
+ const trees = [...ginkgoSpots, ...cherrySpots]
+ const trunks = new THREE.InstancedMesh(trunkGeo, trunkMat, trees.length)
+ trees.forEach((t, i) => {
+ tmpM.compose(tmpP.set(t.x, 0, t.z), tmpQ.identity(), tmpS.set(1, t.h * 0.55, 1))
+ trunks.setMatrixAt(i, tmpM)
+ })
+ trunks.castShadow = true
+ const ginkgoCanopy = new THREE.InstancedMesh(ginkgoGeo, ginkgoMat, ginkgoSpots.length)
+ const cherryCanopy = new THREE.InstancedMesh(cherryGeo, cherryMat, cherrySpots.length)
+ ginkgoCanopy.castShadow = cherryCanopy.castShadow = true
+ world.add(trunks, ginkgoCanopy, cherryCanopy)
+
+ function layoutCanopies(amount: number) {
+ ginkgoSpots.forEach((t, i) => {
+ const s = Math.max(0.0001, amount)
+ tmpM.compose(tmpP.set(t.x, t.h * 0.35, t.z), tmpQ.identity(), tmpS.set(t.r * s, t.h * 0.7 * s, t.r * s))
+ ginkgoCanopy.setMatrixAt(i, tmpM)
+ })
+ cherrySpots.forEach((t, i) => {
+ const s = Math.max(0.0001, amount)
+ tmpM.compose(tmpP.set(t.x, t.h * 0.72, t.z), tmpQ.identity(), tmpS.set(t.r * s, t.r * s, t.r * s))
+ cherryCanopy.setMatrixAt(i, tmpM)
+ })
+ ginkgoCanopy.instanceMatrix.needsUpdate = true
+ cherryCanopy.instanceMatrix.needsUpdate = true
+ }
+
+ /* 태어난 해에 심은 벚나무. 공원 가운데, 산책로가 둘러싼다.
+ 4월생이라 해마다 생일 무렵에 꽃이 핀다. */
+ const pathRing = new THREE.Mesh(
+ new THREE.RingGeometry(4.6, 6.0, 48),
+ new THREE.MeshStandardMaterial({ color: 0xdccfb3, roughness: 1 }),
+ )
+ pathRing.rotation.x = -Math.PI / 2
+ pathRing.position.set(P.park.x, 0.03, P.park.z)
+ pathRing.receiveShadow = true
+ world.add(pathRing)
+
+ const benchMat = new THREE.MeshStandardMaterial({ color: 0x8c6a4a, roughness: 0.8 })
+ for (let i = 0; i < 3; i++) {
+ const a = (i / 3) * Math.PI * 2 + 0.9
+ const bench = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.45, 0.5), benchMat)
+ bench.position.set(P.park.x + Math.cos(a) * 6.8, 0.23, P.park.z + Math.sin(a) * 6.8)
+ bench.rotation.y = -a + Math.PI / 2
+ bench.castShadow = true
+ world.add(bench)
+ }
+
+ const birthTree = new THREE.Group()
+ const btTrunk = new THREE.Mesh(trunkGeo, trunkMat)
+ btTrunk.scale.set(1.1, 2.6, 1.1)
+ const btCanopy = new THREE.Mesh(cherryGeo, cherryMat)
+ btCanopy.position.y = 3.0
+ btCanopy.scale.setScalar(1.9)
+ btTrunk.castShadow = btCanopy.castShadow = true
+ birthTree.add(btTrunk, btCanopy)
+ birthTree.position.set(P.park.x, 0, P.park.z)
+ world.add(birthTree)
+
+ /* ================================================================
+ 사람과 차 — 동네가 살아 있다는 가장 싼 증거
+ ================================================================ */
+ const skinMat = new THREE.MeshStandardMaterial({ color: 0xe8c4a2, roughness: 0.8 })
+
+ function person(shirt: number, pants: number, headRatio: number): THREE.Group {
+ /* 키 1 기준으로 만들고 바깥에서 키만큼 늘린다. 아이는 머리 비율이 크다 */
+ const g = new THREE.Group()
+ const legs = new THREE.Mesh(new THREE.CapsuleGeometry(0.085, 0.38, 4, 8), new THREE.MeshStandardMaterial({ color: pants, roughness: 0.9 }))
+ legs.position.y = 0.26
+ const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 0.26, 4, 8), new THREE.MeshStandardMaterial({ color: shirt, roughness: 0.85 }))
+ body.position.y = 0.62
+ const head = new THREE.Mesh(new THREE.SphereGeometry(headRatio, 14, 10), skinMat)
+ head.position.y = 0.86 + headRatio * 0.5
+ for (const m of [legs, body, head]) m.castShadow = true
+ g.add(legs, body, head)
+ return g
+ }
+
+ const family = new THREE.Group()
+ const parent = person(0x3f6f8f, 0x39424c, 0.075)
+ parent.scale.setScalar(ADULT)
+ const child = person(0xf2b93c, 0x5d6f86, 0.11)
+ child.position.set(0.55, 0, 0.1)
+ /* 책가방 — 취학을 앞둔 해에만 멘다 */
+ const backpack = new THREE.Mesh(new RoundedBoxGeometry(0.2, 0.24, 0.12, 2, 0.03), new THREE.MeshStandardMaterial({ color: STAMP, roughness: 0.6 }))
+ backpack.position.set(0, 0.64, -0.14)
+ backpack.castShadow = true
+ child.add(backpack)
+
+ const stroller = new THREE.Group()
+ {
+ const frameMat = new THREE.MeshStandardMaterial({ color: 0x2f3439, roughness: 0.6, metalness: 0.3 })
+ const basket = new THREE.Mesh(new RoundedBoxGeometry(0.5, 0.36, 0.78, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0x9fb8a2, roughness: 0.8 }))
+ basket.position.set(0, 0.62, 0)
+ const hood = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), basket.material)
+ hood.position.set(0, 0.78, -0.2)
+ hood.rotation.x = -0.4
+ const handle = new THREE.Mesh(new THREE.BoxGeometry(0.46, 0.04, 0.04), frameMat)
+ handle.position.set(0, 1.02, -0.62)
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.04, 0.6), frameMat)
+ bar.position.set(0, 0.84, -0.42)
+ bar.rotation.x = 0.7
+ stroller.add(basket, hood, handle, bar)
+ const wheelGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.05, 12)
+ wheelGeo.rotateZ(Math.PI / 2)
+ for (const [x, z] of [[-0.24, -0.28], [0.24, -0.28], [-0.24, 0.3], [0.24, 0.3]] as const) {
+ const w = new THREE.Mesh(wheelGeo, frameMat)
+ w.position.set(x, 0.12, z)
+ stroller.add(w)
+ }
+ stroller.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) o.castShadow = true
+ })
+ stroller.position.set(0, 0, 0.95)
+ }
+
+ family.add(parent, child, stroller)
+ world.add(family)
+
+ /* 지나가는 사람들 — 인스턴스 둘(몸·머리)로 그린다 */
+ type Walker = { axis: 'x' | 'z'; lane: number; start: number; speed: number; h: number }
+ const WALKERS: Walker[] = []
+ for (let i = 0; i < 16; i++) {
+ const axis = i % 2 === 0 ? 'x' : 'z'
+ const lane = (i % 4 < 2 ? -1 : 1) * 4.7
+ WALKERS.push({ axis, lane, start: ((i * 37) % 100) - 50, speed: (i % 3 === 0 ? -1 : 1) * (0.9 + (i % 5) * 0.12), h: i % 5 === 0 ? 1.15 : 1.58 + (i % 4) * 0.06 })
+ }
+ const walkerBody = new THREE.InstancedMesh(new THREE.CapsuleGeometry(0.16, 0.6, 4, 8), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.85 }), WALKERS.length)
+ const walkerHead = new THREE.InstancedMesh(new THREE.SphereGeometry(0.12, 10, 8), skinMat, WALKERS.length)
+ const COATS = [0x5b7fa6, 0xc2453d, 0xe0c068, 0x6f8f5a, 0x8b8f96, 0xe8e2d4, 0x39424c]
+ WALKERS.forEach((_, i) => walkerBody.setColorAt(i, new THREE.Color(COATS[i % COATS.length])))
+ walkerBody.castShadow = walkerHead.castShadow = true
+ world.add(walkerBody, walkerHead)
+
+ /* 차 — 노란 어린이집 차량 한 대를 섞는다 */
+ type Car = { g: THREE.Group; axis: 'x' | 'z'; lane: number; dir: 1 | -1; start: number; speed: number }
+ const cars: Car[] = []
+ const CAR_SPEC: [number, 'x' | 'z', 1 | -1, number, number][] = [
+ [0xf2f2ee, 'x', 1, -40, 7],
+ [0x3a4450, 'x', -1, 10, 6],
+ [0xf2b93c, 'x', 1, 20, 5],
+ [0x8d949b, 'z', 1, -20, 6.5],
+ [0x2f5f8f, 'z', -1, 35, 5.5],
+ ]
+ CAR_SPEC.forEach(([color, axis, dir, start, speed]) => {
+ const g = new THREE.Group()
+ const body = new THREE.Mesh(new RoundedBoxGeometry(4.2, 1.0, 1.8, 2, 0.18), new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.2 }))
+ body.position.y = 0.72
+ const cabin = new THREE.Mesh(new RoundedBoxGeometry(2.3, 0.8, 1.6, 2, 0.16), new THREE.MeshStandardMaterial({ color: 0x33414c, roughness: 0.2, metalness: 0.3 }))
+ cabin.position.set(-0.25, 1.5, 0)
+ body.castShadow = cabin.castShadow = true
+ g.add(body, cabin)
+ world.add(g)
+ /* 우측 통행 — 진행 방향 오른쪽 차로 */
+ const lane = axis === 'x' ? (dir === 1 ? 2 : -2) : dir === 1 ? -2 : 2
+ cars.push({ g, axis, lane, dir, start, speed })
+ })
+
+ /* ================================================================
+ 알림 표식 — 그 시기에 부모가 받는 알림이 어느 건물에서 오는지
+ ================================================================ */
+ type Pin = { g: THREE.Group; mat: THREE.MeshStandardMaterial; y: number; place: Place }
+ const pinGeo = new THREE.SphereGeometry(0.55, 18, 12)
+ const pinTip = new THREE.ConeGeometry(0.38, 0.9, 14)
+ pinTip.rotateX(Math.PI)
+ pinTip.translate(0, -0.55, 0)
+
+ function pin(x: number, y: number, z: number, place: Place): Pin {
+ const mat = new THREE.MeshStandardMaterial({ color: SEAL, emissive: SEAL, emissiveIntensity: 0.4, roughness: 0.4 })
+ const g = new THREE.Group()
+ g.add(new THREE.Mesh(pinGeo, mat), new THREE.Mesh(pinTip, mat))
+ g.position.set(x, y, z)
+ g.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) o.castShadow = true
+ })
+ world.add(g)
+ return { g, mat, y, place }
+ }
+
+ const pins = [
+ pin(P.daycare.x, 6.4 + 2.4, P.daycare.z, 'daycare'),
+ pin(P.clinic.x, 10.2 + 2.4, P.clinic.z, 'clinic'),
+ pin(P.center.x, 7.2 + 2.4, P.center.z, 'center'),
+ ]
+
+ /* 개월 수에 따라 어느 알림이 살아 있는가. 페이지 본문이 말하는 서비스와 같은 순서다.
+ - 행정복지센터: 부모급여(0–23개월). 끝나기 직전에는 마감 도장색
+ - 어린이집: 대기를 거는 무렵부터 유치원으로 넘어가기 전까지
+ - 소아과: 접종·검진이 계속 온다 */
+ function pinState(place: Place, m: number): { on: number; deadline: boolean } {
+ if (place === 'center') return { on: m <= 24 ? 1 : 0.25, deadline: m >= 20 && m <= 24 }
+ if (place === 'daycare') return { on: m < 3 ? 0.2 : m <= 40 ? 1 : 0.3, deadline: false }
+ return { on: 0.7, deadline: false }
+ }
+
+ /* ================================================================
+ 개월 수 적용
+ ================================================================ */
+ const cA = new THREE.Color()
+ const cB = new THREE.Color()
+ const snowColor = new THREE.Color(0xf3f5f7)
+ const sealColor = new THREE.Color(SEAL)
+ const stampColor = new THREE.Color(STAMP)
+ const lookNow = new THREE.Vector3()
+ let distNow = 60
+ let focusNow: Place = 'home'
+ const famFrom = new THREE.Vector3()
+ const famTo = new THREE.Vector3()
+ let famHeading = 0
+
+ function mix(a: number, b: number, k: number): THREE.Color {
+ return cA.set(a).lerp(cB.set(b), k)
+ }
+
+ function applyMonth(m: number) {
+ /* 계절 */
+ const s = seasonAt(m)
+ const { a, b, k } = s
+ const snow = lerp(a.snow, b.snow, k)
+
+ skyColor.copy(mix(a.sky, b.sky, k))
+ fog.color.copy(skyColor)
+ hemi.color.copy(skyColor)
+ hemi.intensity = lerp(a.ambI, b.ambI, k)
+ sun.color.copy(mix(a.sun, b.sun, k))
+ sun.intensity = lerp(a.sunI, b.sunI, k)
+ const el = lerp(a.sunEl, b.sunEl, k)
+ /* 남서쪽 하늘. 그림자가 카메라 쪽(+x)으로 떨어져야 입체가 읽힌다 */
+ sun.position.set(-Math.cos(el) * 110, Math.sin(el) * 110, Math.cos(el) * 42)
+
+ grassMat.color.copy(mix(a.grass, b.grass, k)).lerp(snowColor, snow * 0.9)
+ roofMat.color.copy(roofBase).lerp(snowColor, snow * 0.95)
+ walkMat.color.copy(walkBase).lerp(snowColor, snow * 0.35)
+ asphaltMat.color.copy(asphaltBase).lerp(snowColor, snow * 0.12)
+ ginkgoMat.color.copy(mix(a.ginkgo, b.ginkgo, k))
+ cherryMat.color.copy(mix(a.cherry, b.cherry, k))
+ const canopy = lerp(a.canopy, b.canopy, k)
+ layoutCanopies(canopy)
+
+ /* 태어난 해의 나무 — 묘목에서 동네 나무 크기로 */
+ const grow = clamp01(m / MAX_MONTH)
+ birthTree.scale.setScalar(0.32 + grow * 0.68)
+ btCanopy.scale.setScalar(1.9 * Math.max(0.0001, canopy))
+
+ /* 가족이 설 곳과 카메라가 볼 곳 */
+ const bt = beatAt(m)
+ famTo.set(lerp(bt.a.at[0], bt.b.at[0], bt.k), 0.16, lerp(bt.a.at[1], bt.b.at[1], bt.k))
+ lookNow.set(lerp(bt.a.look[0], bt.b.look[0], bt.k), 1.5, lerp(bt.a.look[1], bt.b.look[1], bt.k))
+ distNow = lerp(bt.a.dist, bt.b.dist, bt.k)
+ focusNow = bt.k < 0.5 ? bt.a.focus : bt.b.focus
+
+ /* 아이 — 첫 돌 무렵까지는 유모차, 그다음은 걷는다 */
+ const walking = clamp01((m - 11) / 2)
+ const h = childHeight(m)
+ child.scale.setScalar(h)
+ child.visible = walking > 0.02
+ stroller.visible = walking < 0.98
+ stroller.scale.setScalar(Math.max(0.0001, 1 - walking))
+ backpack.visible = m >= 66
+
+ /* 알림 표식 색 — 부모급여가 끝나기 직전에는 도장 빨강 */
+ for (const p of pins) {
+ const st = pinState(p.place, m)
+ const target = st.deadline ? stampColor : sealColor
+ p.mat.color.copy(target)
+ p.mat.emissive.copy(target)
+ p.g.userData.on = st.on
+ }
+
+ sun.shadow.needsUpdate = true
+ }
+
+ /* ================================================================
+ 리사이즈 · 루프
+ ================================================================ */
+ function layout() {
+ const r = host.getBoundingClientRect()
+ /* 넓은 화면에서만 피사체를 오른쪽으로 민다 — 좁으면 화면 밖으로 나간다.
+ 값은 probe() 로 주요 건물의 화면 좌표를 재서 맞췄다. */
+ panRatio = r.width >= 1080 ? 0.16 : r.width > 900 ? 0.1 : 0
+ liftRatio = r.width > 900 ? 0 : 0.2
+ const aspect = r.width / Math.max(1, r.height)
+ distScale = Math.min(2.2, Math.max(1, 1.15 / aspect))
+ sizeToHost(host, renderer, camera)
+ }
+
+ let targetMonth = 0
+ let shownMonth = -1
+ let elapsed = 0
+ let shadowTick = 0
+ let perfSince = 0
+ let perfFrames = 0
+ let perfMs = 0
+ let perfFps = 0
+ let perfJsMs = 0
+
+ function updateWalkers(time: number) {
+ WALKERS.forEach((w, i) => {
+ const along = ((((w.start + time * w.speed) % 100) + 150) % 100) - 50
+ const x = w.axis === 'x' ? along : w.lane
+ const z = w.axis === 'x' ? w.lane : along
+ const bob = Math.abs(Math.sin(time * 5 + i)) * 0.04
+ const heading = w.axis === 'x' ? (w.speed > 0 ? Math.PI / 2 : -Math.PI / 2) : w.speed > 0 ? 0 : Math.PI
+ tmpQ.setFromAxisAngle(UP, heading)
+ tmpM.compose(tmpP.set(x, 0.16 + w.h * 0.36 + bob, z), tmpQ, tmpS.set(1, w.h / 1.6, 1))
+ walkerBody.setMatrixAt(i, tmpM)
+ tmpM.compose(tmpP.set(x, 0.16 + w.h * 0.92 + bob, z), tmpQ, tmpS.set(1, 1, 1))
+ walkerHead.setMatrixAt(i, tmpM)
+ })
+ walkerBody.instanceMatrix.needsUpdate = true
+ walkerHead.instanceMatrix.needsUpdate = true
+ }
+
+ function updateCars(time: number) {
+ for (const c of cars) {
+ const along = ((((c.start + time * c.speed * c.dir) % 120) + 180) % 120) - 60
+ if (c.axis === 'x') {
+ c.g.position.set(along, 0, c.lane)
+ c.g.rotation.y = c.dir === 1 ? 0 : Math.PI
+ } else {
+ c.g.position.set(c.lane, 0, along)
+ c.g.rotation.y = c.dir === 1 ? -Math.PI / 2 : Math.PI / 2
+ }
+ }
+ }
+
+ const gate = createLoopGate((dt) => {
+ elapsed += dt
+ const frameStart = performance.now()
+
+ /* 스크롤은 계단처럼 튀기 쉽다. 보이는 개월 수를 목표값으로 부드럽게 끌고 간다.
+ 가려진 구간을 지나 다시 보일 때는 밀린 계절이 빠르게 지나간다. */
+ if (shownMonth < 0) shownMonth = targetMonth
+ const diff = targetMonth - shownMonth
+ if (Math.abs(diff) > 0.002) {
+ shownMonth += diff * (1 - Math.exp(-dt * 4.5))
+ applyMonth(shownMonth)
+ } else if (shownMonth !== targetMonth) {
+ shownMonth = targetMonth
+ applyMonth(shownMonth)
+ }
+
+ /* 가족은 목표 지점으로 걸어간다 — 순간이동하지 않는다 */
+ famFrom.copy(family.position)
+ family.position.lerp(famTo, 1 - Math.exp(-dt * 3))
+ const moved = famFrom.distanceTo(family.position)
+ if (moved > 0.002) {
+ famHeading = Math.atan2(family.position.x - famFrom.x, family.position.z - famFrom.z)
+ }
+ /* 가장 짧은 쪽으로 돈다. 그냥 빼면 ±180° 경계에서 한 바퀴를 크게 돈다 */
+ const turn = ((famHeading - family.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI
+ family.rotation.y += turn * (1 - Math.exp(-dt * 6))
+ const stride = clamp01(moved / (dt * 1.2 + 1e-6))
+ parent.position.y = Math.abs(Math.sin(elapsed * 6)) * 0.035 * stride
+ child.position.y = Math.abs(Math.sin(elapsed * 7 + 1)) * 0.03 * stride
+
+ /* 알림 표식은 살아 있는 만큼 떠오르고 빛난다 */
+ for (const p of pins) {
+ const on = (p.g.userData.on as number | undefined) ?? 0.5
+ const focus = p.place === focusNow ? 1 : 0
+ const glow = on * (0.35 + focus * 0.45) + Math.max(0, Math.sin(elapsed * 2.4)) * 0.25 * focus
+ p.mat.emissiveIntensity = glow
+ p.g.position.y = p.y + Math.sin(elapsed * 1.6 + p.y) * 0.18 + on * 0.4
+ p.g.scale.setScalar(0.85 + on * 0.3 + focus * 0.25)
+ }
+
+ updateWalkers(elapsed)
+ updateCars(elapsed)
+
+ /* 카메라는 개월 수를 따라 돈다 — 여섯 해 동안 동네를 한 바퀴의 1/5 쯤 */
+ placeCamera(lookNow, distNow, (shownMonth / MAX_MONTH) * 0.9 - 0.2)
+
+ /* 사람과 차가 움직이므로 그림자를 가끔 다시 굽는다. 6프레임에 한 번이면 끊겨 보이지 않는다 */
+ shadowTick += 1
+ if (shadowTick >= 6) {
+ sun.shadow.needsUpdate = true
+ shadowTick = 0
+ }
+
+ renderer.render(scene, camera)
+
+ /* 창 하나 분량의 프레임 통계. stats() 가 이 값을 읽는다 */
+ perfMs += performance.now() - frameStart
+ perfFrames += 1
+ if (frameStart - perfSince >= 1000) {
+ perfFps = (perfFrames * 1000) / (frameStart - perfSince)
+ perfJsMs = perfMs / Math.max(1, perfFrames)
+ perfSince = frameStart
+ perfFrames = 0
+ perfMs = 0
+ }
+ })
+
+ const stopResize = observeResize(host, layout)
+ layout()
+ applyMonth(0)
+ family.position.copy(famTo)
+
+ return {
+ setMonth(m) {
+ targetMonth = Math.min(MAX_MONTH, Math.max(0, m))
+ },
+ setVisible(v) {
+ gate.setAllowed(v)
+ },
+ probe() {
+ const out: Record = {}
+ const v = new THREE.Vector3()
+ const spots: Record = {
+ home: [P.home.x, 6, P.home.z],
+ clinic: [P.clinic.x, 5, P.clinic.z],
+ daycare: [P.daycare.x, 3, P.daycare.z],
+ center: [P.center.x, 3.6, P.center.z],
+ tree: [P.park.x, 2, P.park.z],
+ family: [family.position.x, 1, family.position.z],
+ }
+ for (const [k, [x, y, z]] of Object.entries(spots)) {
+ v.set(x, y, z).project(camera)
+ out[k] = [Math.round((v.x * 0.5 + 0.5) * 100), Math.round((0.5 - v.y * 0.5) * 100)]
+ }
+ return out
+ },
+ stats() {
+ return {
+ fps: Math.round(perfFps),
+ jsMs: +perfJsMs.toFixed(2),
+ drawCalls: renderer.info.render.calls,
+ triangles: renderer.info.render.triangles,
+ geometries: renderer.info.memory.geometries,
+ textures: renderer.info.memory.textures,
+ tier: TIER,
+ pixelRatio: renderer.getPixelRatio(),
+ }
+ },
+ dispose() {
+ gate.dispose()
+ stopResize()
+ disposeObject3D(world)
+ renderer.dispose()
+ canvas.remove()
+ },
+ }
+}
diff --git a/src/features/town/types.ts b/src/features/town/types.ts
new file mode 100644
index 0000000..c385422
--- /dev/null
+++ b/src/features/town/types.ts
@@ -0,0 +1,26 @@
+/**
+ * 동네 장면이 바깥에 노출하는 손잡이.
+ *
+ * 스크롤 엔진은 이 타입만 알면 된다. three.js 를 직접 import 하지 않으므로
+ * 장면 모듈이 지연 로드 청크로 따로 떨어진다.
+ */
+export type TownHandle = {
+ /** 아이의 개월 수 0~72. 계절·성장·카메라가 이 값 하나로 정해진다. */
+ setMonth(m: number): void
+ /** 장면이 가려지는 구간에서 렌더 루프를 세운다 */
+ setVisible(v: boolean): void
+ /** 프레이밍 측정용 — 주요 건물의 화면 좌표(%) */
+ probe(): Record
+ /** 성능 측정용 — 실제 GPU 에서만 의미 있는 값들 */
+ stats(): {
+ fps: number
+ jsMs: number
+ drawCalls: number
+ triangles: number
+ geometries: number
+ textures: number
+ tier: string
+ pixelRatio: number
+ }
+ dispose(): void
+}
diff --git a/src/styles/grow.css b/src/styles/grow.css
new file mode 100644
index 0000000..b0b58c3
--- /dev/null
+++ b/src/styles/grow.css
@@ -0,0 +1,884 @@
+/*
+ * 홈 — 여섯 해(출생 → 72개월).
+ *
+ * 스크롤 진행이 곧 아이의 개월 수다. 그 개월 수가 뒤에 깔린 동네(3D)의 계절·아이의 키·
+ * 카메라가 보는 건물을 정한다. 이 파일은 그 무대와 구간의 문법만 가진다.
+ *
+ * 색·서체·도장은 globals.css 의 육아수첩 토큰을 그대로 쓴다. 이 파일이 새로 만드는 색은 없다.
+ * 클래스는 전부 yr- 로 시작한다 — Tailwind 유틸리티 이름(gap, table, grid …)과 부딪히지 않게.
+ */
+
+:root {
+ --pad: 24px;
+ /* 왼쪽 개월 레일의 폭. 본문 폭이 양쪽에서 이만큼 비켜 선다 */
+ --rail: 88px;
+ --shell: min(1180px, 100% - 2 * max(var(--pad), var(--rail)));
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
+ --grid:
+ linear-gradient(to right, var(--rule) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--rule) 1px, transparent 1px);
+}
+
+@media (max-width: 900px) {
+ :root {
+ --pad: 18px;
+ --rail: 0px;
+ }
+}
+
+.yr-shell {
+ width: var(--shell);
+ margin-inline: auto;
+}
+
+/* 한국어는 기본값이면 음절 단위로 잘린다 — 어절로만 끊는다 */
+.yr-station,
+.yr-hero,
+.yr-close {
+ word-break: keep-all;
+ overflow-wrap: break-word;
+}
+
+/* ============================================================
+ 무대 — 동네 캔버스와 스크림
+ ============================================================ */
+.yr-sky {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ opacity: var(--sky-op, 1);
+ transition: opacity 0.45s linear;
+ pointer-events: none;
+ background: var(--paper);
+}
+
+.yr-sky canvas {
+ width: 100%;
+ height: 100%;
+}
+
+/* WebGL2 가 없거나 동작 줄이기를 켰을 때. 봄 하늘과 잔디 한 줄 — 동네 대신 계절만 남긴다 */
+.yr-sky__fallback {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(70% 50% at 78% 70%, rgba(244, 195, 211, 0.55), transparent 70%),
+ radial-gradient(60% 40% at 88% 58%, rgba(157, 195, 124, 0.45), transparent 70%),
+ linear-gradient(180deg, #e3edf1 0%, #eef3ee 58%, #d6e6c4 58.2%, #bfd6a6 100%);
+}
+
+/* 카피가 앉는 왼쪽을 종이색으로 누른다. 풍경은 오른쪽에서 산다 */
+.yr-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ opacity: var(--sky-op, 1);
+ transition: opacity 0.45s linear;
+ background: linear-gradient(94deg, rgba(246, 248, 243, 0.78) 0%, rgba(246, 248, 243, 0.5) 32%, rgba(246, 248, 243, 0) 58%);
+}
+
+@media (max-width: 900px) {
+ .yr-scrim {
+ background: linear-gradient(180deg, rgba(246, 248, 243, 0.4) 0%, rgba(246, 248, 243, 0) 45%);
+ }
+}
+
+/* 고정 캔버스(.yr-sky)가 z-index:0 의 위치 지정 요소라, 위치 지정이 없는 요소는 그 아래에
+ 그려진다. 레이아웃의 푸터가 통째로 캔버스 뒤에 깔리지 않게 올린다. */
+body:has(.yr-sky) > footer {
+ position: relative;
+ z-index: 1;
+ background: var(--paper);
+}
+
+/* ============================================================
+ 개월 레일 — 페이지의 척추. 시간 순서가 정보 자체라서 장식이 아니다
+ ============================================================ */
+.yr-rail {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: var(--rail);
+ z-index: 30;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ pointer-events: none;
+}
+
+/* 한글은 세로로 눕히면 글자가 옆으로 누워 읽히지 않는다. 레일 폭 안에서 가로로 쓴다 */
+.yr-rail__now {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: var(--ink);
+ white-space: nowrap;
+ text-align: center;
+}
+
+.yr-rail__track {
+ position: relative;
+ width: 1px;
+ flex: 0 1 44vh;
+ background: var(--rule);
+}
+
+.yr-rail__fill {
+ position: absolute;
+ inset: 0 0 auto 0;
+ height: calc(var(--rd, 0) * 100%);
+ background: var(--seal);
+}
+
+.yr-rail__ticks {
+ position: absolute;
+ inset: 0;
+}
+
+.yr-rail__tick {
+ position: absolute;
+ left: -3px;
+ width: 7px;
+ height: 1px;
+ background: var(--past);
+}
+
+.yr-rail__tick[data-on='1'] {
+ background: var(--seal);
+ left: -5px;
+ width: 11px;
+}
+
+.yr-rail__cap {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ color: var(--ink-soft);
+ white-space: nowrap;
+}
+
+@media (max-width: 900px) {
+ .yr-rail {
+ inset: auto 0 0 0;
+ width: 100%;
+ height: 44px;
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 12px;
+ padding: 0 var(--pad);
+ background: rgba(246, 248, 243, 0.92);
+ backdrop-filter: blur(10px);
+ border-top: 1px solid var(--rule);
+ }
+
+ .yr-rail__now {
+ order: 3;
+ min-width: 4.2em;
+ text-align: right;
+ }
+
+ .yr-rail__track {
+ height: 1px;
+ width: auto;
+ flex: 1 1 auto;
+ }
+
+ .yr-rail__fill {
+ inset: 0 auto 0 0;
+ height: 100%;
+ width: calc(var(--rd, 0) * 100%);
+ }
+
+ .yr-rail__tick {
+ left: auto;
+ top: -3px;
+ width: 1px;
+ height: 7px;
+ }
+
+ .yr-rail__tick[data-on='1'] {
+ top: -5px;
+ height: 11px;
+ width: 1px;
+ }
+
+ /* 하단 레일이 푸터 마지막 줄을 가리지 않게 */
+ body:has(.yr-rail) {
+ padding-bottom: 44px;
+ }
+}
+
+/* ============================================================
+ 구간
+ ============================================================ */
+.yr-station {
+ position: relative;
+ z-index: 1;
+ padding: 132px 0;
+ background: var(--grid) -1px -1px / 28px 28px, var(--paper);
+ /* 위아래 가장자리를 풀어 둔다. 앞뒤 구간에서 동네가 보일 때 칼자국 대신 풍경이 스며든다.
+ 패딩(132px)이 페이드(112px)보다 넓어 글자는 흐려지지 않는다. */
+ mask-image: linear-gradient(180deg, transparent 0, #000 112px, #000 calc(100% - 112px), transparent 100%);
+}
+
+/* 동네가 주인공인 구간 — 지면을 비우고 글은 종이 판에 올린다 */
+.yr-station--open {
+ background: transparent;
+ mask-image: none;
+ min-height: 100svh;
+ display: grid;
+ align-content: center;
+ padding: 112px 0;
+}
+
+@media (max-width: 900px) {
+ .yr-station {
+ padding: 88px 0;
+ mask-image: linear-gradient(180deg, transparent 0, #000 64px, #000 calc(100% - 64px), transparent 100%);
+ }
+
+ /* 좁은 화면은 판이 화면을 다 덮으므로 풍경이 보일 틈을 위쪽에 둔다 */
+ .yr-station--open {
+ min-height: 0;
+ padding: 46svh 0 64px;
+ }
+}
+
+.yr-head {
+ display: grid;
+ grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
+ gap: 36px 56px;
+ align-items: end;
+ margin-bottom: 88px;
+}
+
+@media (max-width: 900px) {
+ .yr-head {
+ grid-template-columns: 1fr;
+ align-items: start;
+ margin-bottom: 56px;
+ }
+}
+
+/* 도장 — 이 구간이 아이의 몇 개월인지. 시퀀스가 정보 자체라 장식이 아니다 */
+.yr-stamp {
+ color: var(--seal-deep);
+ font-size: 0.75rem;
+ margin-bottom: 22px;
+}
+
+/* 도장의 숫자 서체(IBM Plex Mono)에는 한글이 없다. 한글이 대체 서체로 넘어가면서 넓은 자간이
+ 글자 사이를 벌려 "6개 월"처럼 읽힌다. 한글이 들어가는 도장은 본문 서체로 찍는다. */
+.yr-station .stamp,
+.yr-hero .stamp,
+.yr-close .stamp {
+ font-family: inherit;
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.yr-h2 {
+ font-size: clamp(1.9rem, 3.6vw, 2.85rem);
+ /* 얇은 표제는 행간을 넉넉히 둬야 두 줄이 한 덩어리로 읽힌다 */
+ line-height: 1.28;
+ margin: 0;
+ text-wrap: balance;
+}
+
+.yr-h3 {
+ font-size: 1.0625rem;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ margin: 88px 0 24px;
+}
+
+.yr-when {
+ margin-left: 10px;
+ font-size: 0.75rem;
+ font-weight: 400;
+ color: var(--ink-soft);
+}
+
+.yr-body {
+ color: var(--ink-soft);
+ font-size: 1.0625rem;
+ line-height: 1.9;
+ max-width: 60ch;
+}
+
+.yr-body p + p {
+ margin-top: 1em;
+}
+
+.yr-body strong,
+.yr-panel__body strong,
+.yr-lede strong {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.yr-fine {
+ margin-top: 28px;
+ max-width: 70ch;
+ font-size: 0.875rem;
+ line-height: 1.85;
+ color: var(--ink-soft);
+}
+
+.yr-fine a,
+.yr-panel a:not(.yr-btn) {
+ color: var(--ink);
+ text-decoration: underline;
+ text-underline-offset: 4px;
+}
+
+/* ============================================================
+ 히어로 · 종이 판
+ ============================================================ */
+.yr-hero {
+ position: relative;
+ z-index: 1;
+ min-height: calc(100svh - 60px);
+ display: grid;
+ align-content: center;
+ padding: 72px 0 96px;
+}
+
+@media (max-width: 900px) {
+ .yr-hero {
+ min-height: 0;
+ padding: 38svh 0 64px;
+ }
+}
+
+/* 풍경 위의 글은 종이 한 장에 올린다. 계절마다 바탕색이 바뀌는 위에서도 대비가 보장된다.
+ 카드 그리드가 아니라 구간마다 판 하나 — 수첩을 펼쳐 든 모양이다. */
+.yr-panel {
+ max-width: 600px;
+ padding: clamp(24px, 3.4vw, 44px);
+ background: rgba(246, 248, 243, 0.93);
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--rule);
+ box-shadow: 4px 4px 0 var(--paper-deep);
+}
+
+.yr-panel__body {
+ margin-top: 22px;
+ font-size: 1rem;
+ line-height: 1.9;
+ color: var(--ink-soft);
+}
+
+.yr-panel__notice {
+ margin: 32px 0 8px;
+}
+
+.yr-panel .yr-h3 {
+ margin-top: 40px;
+}
+
+.yr-display {
+ margin-top: 20px;
+ font-size: clamp(2.5rem, 5.2vw, 4.2rem);
+ line-height: 1.2;
+ letter-spacing: -0.04em;
+}
+
+.yr-lede {
+ margin-top: 26px;
+ max-width: 44ch;
+ font-size: clamp(1.02rem, 1.3vw, 1.125rem);
+ line-height: 1.85;
+ color: var(--ink-soft);
+}
+
+.yr-hero__cta {
+ margin-top: 34px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.yr-btn {
+ display: inline-flex;
+ align-items: center;
+ min-height: 46px;
+ padding: 0 22px;
+ font-size: 0.95rem;
+ text-decoration: none;
+ border: 1px solid var(--ink);
+ transition: background-color 0.16s var(--ease), color 0.16s var(--ease);
+}
+
+.yr-btn--primary {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.yr-btn--primary:hover {
+ background: var(--seal-deep);
+ border-color: var(--seal-deep);
+}
+
+.yr-btn--ghost {
+ background: transparent;
+ color: var(--ink);
+ border-color: var(--rule);
+}
+
+.yr-btn--ghost:hover {
+ border-color: var(--ink);
+}
+
+.yr-hint {
+ margin-top: 28px;
+ font-size: 0.75rem;
+ letter-spacing: 0.04em;
+ color: var(--ink-soft);
+}
+
+/* ============================================================
+ 구조 문법 — 카드가 아니라 규칙선과 행이 구조를 만든다
+ ============================================================ */
+
+/* 마주 놓는 두 단위 (1개월) */
+.yr-gap {
+ border-top: 1px solid var(--ink);
+}
+
+.yr-gap__row {
+ display: grid;
+ grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
+ gap: 24px;
+ padding: 18px 0;
+ border-bottom: 1px solid var(--rule);
+ align-items: baseline;
+}
+
+.yr-gap__head {
+ font-size: 0.75rem;
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+}
+
+.yr-gap__row > span:nth-child(2) {
+ color: var(--ink-soft);
+}
+
+.yr-gap__parent {
+ color: var(--ink);
+}
+
+.yr-gap__row:not(.yr-gap__head) .yr-gap__parent::before {
+ content: '';
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 10px;
+ background: var(--seal);
+ vertical-align: 0.15em;
+}
+
+@media (max-width: 700px) {
+ .yr-gap__row {
+ grid-template-columns: 64px minmax(0, 1fr);
+ gap: 6px 16px;
+ }
+
+ .yr-gap__row > span:nth-child(3) {
+ grid-column: 2;
+ }
+
+ .yr-gap__head > span:first-child {
+ display: none;
+ }
+
+ .yr-gap__head > span:nth-child(2) {
+ grid-column: 2;
+ }
+}
+
+/* 수치 한 줄 */
+.yr-figures {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 32px 40px;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.yr-figures--3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.yr-figures li {
+ border-top: 1px solid var(--rule);
+ padding-top: 16px;
+}
+
+.yr-figures__v {
+ font-size: 1.85rem;
+ line-height: 1;
+ color: var(--ink);
+}
+
+.yr-figures__v small {
+ margin-left: 4px;
+ font-size: 0.9rem;
+ color: var(--ink-soft);
+}
+
+.yr-figures__k {
+ margin-top: 12px;
+ font-size: 0.9375rem;
+}
+
+.yr-figures__n {
+ margin-top: 4px;
+ font-size: 0.8125rem;
+ line-height: 1.7;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 900px) {
+ .yr-figures,
+ .yr-figures--3 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 480px) {
+ .yr-figures--3 {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 조건 → 이유 두 칸 */
+.yr-rows {
+ margin: 0;
+}
+
+.yr-rows__row {
+ display: grid;
+ grid-template-columns: 180px minmax(0, 1fr);
+ gap: 6px 24px;
+ padding: 14px 0;
+ border-top: 1px solid var(--rule);
+ font-size: 0.9375rem;
+ line-height: 1.75;
+}
+
+.yr-rows--wide .yr-rows__row {
+ grid-template-columns: 200px minmax(0, 1fr);
+ padding: 20px 0;
+ font-size: 1rem;
+}
+
+.yr-rows dt {
+ color: var(--ink);
+}
+
+.yr-rows dd {
+ margin: 0;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 620px) {
+ .yr-rows__row,
+ .yr-rows--wide .yr-rows__row {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 월령 타임라인을 올려 두는 판 (12개월) */
+.yr-play {
+ border: 1px solid var(--rule);
+ background: var(--paper);
+ box-shadow: 4px 4px 0 var(--paper-deep);
+ padding: clamp(22px, 3.4vw, 40px);
+}
+
+/* 고친 기록 원장 (24개월). 안 바뀐 것도 결정이지만, 여기서는 원인이 곧 설명이다 */
+.yr-ledger {
+ border-top: 1px solid var(--ink);
+}
+
+.yr-ledger__row {
+ display: grid;
+ grid-template-columns: 120px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr);
+ gap: 24px;
+ padding: 18px 0;
+ border-bottom: 1px solid var(--rule);
+ align-items: baseline;
+ font-size: 0.9375rem;
+}
+
+.yr-ledger__head {
+ font-size: 0.75rem;
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+}
+
+.yr-ledger__n {
+ color: var(--ink);
+}
+
+.yr-ledger__a {
+ color: var(--past);
+}
+
+.yr-ledger__b {
+ color: var(--seal-deep);
+}
+
+.yr-ledger__d {
+ color: var(--ink-soft);
+ line-height: 1.75;
+}
+
+.yr-ledger__total {
+ border-top: 1px solid var(--ink);
+ border-bottom: 0;
+ padding-top: 22px;
+}
+
+.yr-ledger__total .yr-ledger__a {
+ text-decoration: line-through;
+}
+
+.yr-ledger__total .figure {
+ font-size: 1.15rem;
+}
+
+@media (max-width: 760px) {
+ .yr-ledger__head {
+ display: none;
+ }
+
+ .yr-ledger__row {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 4px 16px;
+ }
+
+ .yr-ledger__n,
+ .yr-ledger__d {
+ grid-column: 1 / -1;
+ }
+
+ .yr-ledger__a::before {
+ content: '처음 ';
+ font-size: 0.75rem;
+ }
+
+ .yr-ledger__b::before {
+ content: '지금 ';
+ font-size: 0.75rem;
+ }
+}
+
+/* 세 갈래 판정 — 왼쪽 색 띠가 결과의 종류를 말한다 */
+.yr-levels {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 1px;
+ background: var(--rule);
+ border: 1px solid var(--rule);
+}
+
+.yr-levels li {
+ display: grid;
+ grid-template-columns: 132px minmax(0, 1fr);
+ gap: 16px 28px;
+ align-items: baseline;
+ padding: 22px 24px 22px 21px;
+ background: var(--paper);
+ border-left: 3px solid var(--c);
+}
+
+.yr-levels p {
+ color: var(--ink-soft);
+ font-size: 0.9375rem;
+ line-height: 1.8;
+}
+
+@media (max-width: 620px) {
+ .yr-levels li {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 아는 것 / 모르는 것 (36개월) — 못 하는 것은 각주가 아니라 정식 구간이다 */
+.yr-cannots {
+ display: grid;
+ gap: 1px;
+ background: var(--rule);
+ border: 1px solid var(--rule);
+}
+
+.yr-cannots__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 40px;
+ padding: 26px 28px;
+ background: var(--paper);
+}
+
+.yr-cannots__row > div + div {
+ border-left: 1px solid var(--rule);
+ padding-left: 40px;
+}
+
+.yr-cannots__v {
+ margin-top: 10px;
+ font-size: 1rem;
+ line-height: 1.7;
+}
+
+.yr-cannots__why {
+ margin-top: 10px;
+ font-size: 0.875rem;
+ line-height: 1.8;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 760px) {
+ .yr-cannots__row {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ padding: 22px;
+ }
+
+ .yr-cannots__row > div + div {
+ border-left: 0;
+ padding-left: 0;
+ border-top: 1px solid var(--rule);
+ padding-top: 20px;
+ }
+}
+
+/* 기록에서 나온 문장 (48개월) */
+.yr-quote {
+ margin: 64px 0 0;
+ max-width: 62ch;
+}
+
+.yr-quote blockquote {
+ margin: 0;
+ padding: 4px 0 4px 22px;
+ border-left: 2px solid var(--seal);
+ font-size: 1.0625rem;
+ line-height: 1.9;
+}
+
+.yr-quote figcaption {
+ margin-top: 14px;
+ padding-left: 24px;
+}
+
+/* ============================================================
+ 닫는 구간 (72개월)
+ ============================================================ */
+.yr-close {
+ position: relative;
+ z-index: 1;
+ min-height: 100svh;
+ display: grid;
+ align-content: center;
+ padding: 96px 0;
+}
+
+.yr-close__grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 360px);
+ gap: 48px;
+ align-items: end;
+}
+
+.yr-close__title {
+ font-size: clamp(2rem, 4vw, 3.1rem);
+ line-height: 1.3;
+ letter-spacing: -0.035em;
+ margin: 0;
+ text-wrap: balance;
+}
+
+.yr-docs {
+ display: grid;
+ gap: 1px;
+ background: var(--rule);
+ border: 1px solid var(--rule);
+ box-shadow: 4px 4px 0 var(--paper-deep);
+}
+
+.yr-doc {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 15px 18px;
+ background: rgba(246, 248, 243, 0.94);
+ backdrop-filter: blur(8px);
+ color: var(--ink);
+ font-size: 0.9375rem;
+ text-decoration: none;
+ transition: background-color 0.16s var(--ease);
+}
+
+.yr-doc:hover {
+ background: var(--paper-deep);
+}
+
+@media (max-width: 980px) {
+ .yr-close__grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 900px) {
+ .yr-close {
+ min-height: 0;
+ padding: 46svh 0 72px;
+ }
+}
+
+/* ============================================================
+ 모션 — 여섯 해가 지나가는 것이 유일한 연출이다. 나머지는 한 가지 등장뿐
+ ============================================================ */
+
+/* 자바스크립트가 없으면 등장 연출을 걸지 않는다 — 관찰자가 못 돌아 본문이 영영 숨는다.
+ 에 클래스를 붙여 가르면 React 가 소유한 className 과 부딪혀 하이드레이션이 어긋난다.
+ 미디어 질의는 DOM 을 건드리지 않고, 모르는 브라우저에서는 본문이 그냥 보인다(안전한 쪽). */
+@media (scripting: enabled) {
+ [data-in] {
+ opacity: 0;
+ transform: translateY(14px);
+ transition:
+ opacity 0.72s var(--ease),
+ transform 0.72s var(--ease);
+ transition-delay: var(--d, 0ms);
+ }
+
+ [data-in].is-in {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-in] {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+
+ .yr-sky,
+ .yr-scrim {
+ transition: none;
+ }
+}
From f606a47d95456d9e1810842a44b62d7f93fa2d4a Mon Sep 17 00:00:00 2001
From: RosieOh
Date: Tue, 22 Sep 2026 17:54:49 +0900
Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=97=AC=EC=84=AF=20=ED=95=B4=20?=
=?UTF-8?q?=ED=99=88=EC=9D=98=20=EA=B5=AC=EC=A1=B0=EC=99=80=20=EC=86=90?=
=?UTF-8?q?=EB=B3=BC=20=EB=95=8C=20=EC=95=8C=EC=95=84=EC=95=BC=20=ED=95=A0?=
=?UTF-8?q?=20=EA=B2=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 58 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index e74ace5..25a3c6b 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ npm run build # out/ 에 정적 파일 생성
| 경로 | 내용 |
|------|------|
-| `/` | 개요. 월령 축을 옮기면 지원금이 유효·소급가능·지남으로 갈립니다 |
+| `/` | 여섯 해. 스크롤이 아이의 개월 수(0 → 72)이고, 뒤의 3D 동네가 그에 맞춰 자랍니다 |
| `/benefits` | 자격 판정, 수령액 계산, 금액 신뢰도 |
| `/facilities` | 정원 관측, 빈자리 알림, 대기 기간 통계 |
| `/data` | 출처 네 곳과 **아는 것 / 모르는 것** 대조 |
@@ -59,9 +59,62 @@ npm run build # out/ 에 정적 파일 생성
### 시그니처
-홈의 **월령 타임라인**입니다. 문구로 설득하는 대신 축을 놓고 직접 옮겨 보게 합니다.
-밴드 구성은 실제 정책 데이터 모델(대상 연령 상·하한, 지급액, 소급 개월)을 따랐고,
-금액을 모르는 정책은 `미상` 으로 둡니다.
+홈 전체가 **아이의 첫 여섯 해**입니다. 스크롤 진행이 곧 개월 수이고, 그 값 하나가 뒤에 깔린
+3D 동네의 세 가지를 동시에 정합니다.
+
+- **계절** — 4월생으로 가정합니다. 벚꽃, 초록, 은행잎, 눈이 차례로 지나갑니다
+- **성장** — 유모차에서 걸음마로, 마지막에는 책가방을 멥니다. 태어난 봄에 심은 벚나무도 함께 자랍니다
+- **시선** — 그 시기에 부모가 드나드는 건물(어린이집 · 소아과 · 행정복지센터)로 카메라가 갑니다.
+ 건물 위의 알림 표식은 그 시기에 살아 있는 알림만큼 떠오르고, 부모급여가 끝나기 직전(20–24개월)에는
+ 도장 빨강으로 바뀝니다
+
+4월생으로 고른 이유는 동네가 보이는 구간(출생 · 6 · 18 · 72개월)이 봄과 가을에 앉기 때문입니다.
+3월생이면 전부 초봄이라 계절이 흐르는 게 안 보입니다.
+
+구간 사이에는 **월령 타임라인**(12개월 구간)이 있습니다. 스크롤이 개월 수를 정하는 페이지에서
+여기서만은 부모가 직접 축을 옮깁니다. 밴드 구성은 실제 정책 데이터 모델(대상 연령 상·하한, 지급액,
+소급 개월)을 따랐고, 금액을 모르는 정책은 `미상` 으로 둡니다.
+
+구조 문법은 [JellySafe 소개 사이트](https://github.com/JellySafe/Introduce)(스크롤 = 하루)에서
+가져왔고, 색·서체·도장은 이 사이트의 육아수첩 토큰을 그대로 씁니다.
+
+## 홈을 손볼 때
+
+```
+src/features/
+├── grow/ 스크롤 엔진(GrowScene) · 구간 목록(stages.ts)
+├── stations/ 구간 하나 = 파일 하나. 전부 서버 컴포넌트
+└── town/ 3D 동네(three.js). GrowScene 이 동적 import 한다
+```
+
+**구간 목록은 두 곳에 있고 순서가 같아야 합니다** — `src/app/page.tsx` 와
+`src/features/grow/stages.ts`. 레일 눈금이 stages.ts 에서 나오고, 개월 수의 닻은 페이지의
+`[data-month]` 에서 읽습니다. 개발 모드에서는 둘이 어긋나면 콘솔에 경고가 뜹니다.
+
+**개월 수는 문서 진행도가 아니라 구간 위치로 정합니다.** 각 구간 머리가 화면 가운데에 온 순간을
+닻으로 삼아 사이를 잇습니다. 구간 길이가 제각각이라 진행도를 선형으로 나누면 "18개월" 도장을 읽는
+순간 장면은 15개월이 됩니다.
+
+**스크롤 경로에 React 상태를 두지 않습니다.** GrowScene 은 초당 60회 도는 경로라 값은 ref 로
+직접 씁니다.
+
+**3D 는 쓰지 않을 사용자에게 받게 하지 않습니다.** 동작 줄이기(`prefers-reduced-motion`)나
+WebGL2 미지원이면 three.js 청크를 import 하기 **전에** 판정하고 정적 배경을 띄웁니다. 카피는
+전부 DOM 이라 3D 가 없어도 페이지는 온전합니다.
+
+> Windows 에서 "애니메이션 효과"를 꺼 두면 브라우저가 동작 줄이기를 보고해 3D 대신 정적 배경이
+> 뜹니다. 고장이 아닙니다.
+
+**프레이밍은 재서 맞춥니다.** 콘솔에서:
+
+```js
+carecodeTown.probe() // { home: [67, 32], daycare: [80, 42], … } ← 화면 좌표 %
+carecodeTown.stats() // { fps, jsMs, drawCalls, triangles, … }
+```
+
+가족이 설 곳 · 카메라가 볼 곳 · 거리는 `town-scene.ts` 의 `BEATS` 한 표에, 계절은 `SEASONS`
+한 표에 있습니다. 건물 벽은 면마다 재질을 나누지 않고 벽/옥상 두 번에 그립니다(`wallBox`) —
+BoxGeometry 에 재질 배열을 주면 상자 하나에 드로우콜이 여섯 번 듭니다.
## 배포
@@ -112,4 +165,4 @@ PR 에서 사람이 볼 것은 하나입니다 — **숫자가 갑자기 크게
## 스택
-Next.js 15 (App Router, 정적 출력) · React 19 · Tailwind CSS v4 · TypeScript
+Next.js 15 (App Router, 정적 출력) · React 19 · Tailwind CSS v4 · TypeScript · three.js (홈의 3D 동네, 지연 로드)
From 775381375610320adbacb7a00b4043fab2de6666 Mon Sep 17 00:00:00 2001
From: RosieOh
Date: Tue, 22 Sep 2026 17:59:36 +0900
Subject: [PATCH 4/4] =?UTF-8?q?fix(home):=20GPU=20=EA=B0=80=EC=86=8D=20?=
=?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=86=8C=ED=94=84=ED=8A=B8=EC=9B=A8?=
=?UTF-8?q?=EC=96=B4=20=EB=A0=8C=EB=8D=94=EB=9F=AC=EC=97=90=EC=84=9C?=
=?UTF-8?q?=EB=8A=94=203D=20=EB=8F=99=EB=84=A4=EB=A5=BC=20=EA=B1=B4?=
=?UTF-8?q?=EB=84=88=EB=9B=B4=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WebGL2 가 있어도 SwiftShader · llvmpipe 처럼 CPU 로 흉내 내는 렌더러에서는 장면이 초당 몇 프레임이라 메인 스레드를 잡아먹고 스크롤이 끊긴다. CI 의 헤드리스 Chrome 에서 홈 로드가 60초를 넘겨 화면 검증이 실패한 것도 이 때문이다. three.js 를 import 하기 전에 렌더러 이름으로 판정해 정적 배경을 띄우고, 확인용으로 ?town=force 를 둔다.
---
README.md | 10 ++++++---
src/features/grow/GrowScene.tsx | 40 ++++++++++++++++++++++-----------
2 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 25a3c6b..077e79b 100644
--- a/README.md
+++ b/README.md
@@ -98,9 +98,13 @@ src/features/
**스크롤 경로에 React 상태를 두지 않습니다.** GrowScene 은 초당 60회 도는 경로라 값은 ref 로
직접 씁니다.
-**3D 는 쓰지 않을 사용자에게 받게 하지 않습니다.** 동작 줄이기(`prefers-reduced-motion`)나
-WebGL2 미지원이면 three.js 청크를 import 하기 **전에** 판정하고 정적 배경을 띄웁니다. 카피는
-전부 DOM 이라 3D 가 없어도 페이지는 온전합니다.
+**3D 는 쓰지 않을 사용자에게 받게 하지 않습니다.** 동작 줄이기(`prefers-reduced-motion`),
+WebGL2 미지원, **GPU 가속 없는 소프트웨어 렌더러**(SwiftShader · llvmpipe)면 three.js 청크를
+import 하기 **전에** 판정하고 정적 배경을 띄웁니다. 소프트웨어 렌더러에서는 이 장면이 초당 몇
+프레임이라 메인 스레드를 잡아먹고 스크롤까지 끊깁니다 — CI 의 헤드리스 Chrome 에서 페이지 로드가
+60초를 넘긴 것도 이 때문이었습니다. 카피는 전부 DOM 이라 3D 가 없어도 페이지는 온전합니다.
+
+GPU 없는 환경에서 장면을 확인만 하려면 주소에 `?town=force` 를 붙입니다.
> Windows 에서 "애니메이션 효과"를 꺼 두면 브라우저가 동작 줄이기를 보고해 3D 대신 정적 배경이
> 뜹니다. 고장이 아닙니다.
diff --git a/src/features/grow/GrowScene.tsx b/src/features/grow/GrowScene.tsx
index edc93ff..38794e6 100644
--- a/src/features/grow/GrowScene.tsx
+++ b/src/features/grow/GrowScene.tsx
@@ -156,14 +156,26 @@ export default function GrowScene(): ReactNode {
/* ── 동네 장면 — 동적 import ────────────────────────────
쓰지 않을 것이 확실하면 아예 받지 않는다. three.js 청크를 받아 파싱한 뒤에
확인하면, 동작 줄이기를 켠 사용자에게는 통째로 버리는 다운로드가 된다. */
- const wantsMotion = !window.matchMedia('(prefers-reduced-motion: reduce)').matches
- const canWebGL2 = (() => {
+ /* ?town=force 는 개발용 손잡이다. 소프트웨어 렌더러에서도 장면을 띄워 프레이밍을 확인한다 */
+ const forced = new URLSearchParams(window.location.search).get('town') === 'force'
+ const wantsMotion = forced || !window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+ /* WebGL2 가 "있다"와 "쓸 만하다"는 다르다. GPU 가속이 없으면 브라우저가 CPU 로 흉내 내는
+ 렌더러(SwiftShader · llvmpipe)를 쓰는데, 이 장면은 거기서 초당 몇 프레임이고 메인 스레드를
+ 잡아먹어 스크롤까지 끊긴다. 그럴 바에는 정적 배경이 낫다. */
+ const gpu = (() => {
try {
- return !!(window.WebGL2RenderingContext && document.createElement('canvas').getContext('webgl2'))
+ const gl = window.WebGL2RenderingContext ? document.createElement('canvas').getContext('webgl2') : null
+ if (!gl) return 'none'
+ const info = gl.getExtension('WEBGL_debug_renderer_info')
+ const name = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : ''
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
+ return /swiftshader|llvmpipe|softpipe|software|basic render/i.test(name) ? 'software' : 'ok'
} catch {
- return false
+ return 'none'
}
})()
+ const usable = gpu === 'ok' || (forced && gpu === 'software')
const showFallback = (reason: string) => {
const fb = sky.querySelector('[data-fallback]')
@@ -171,16 +183,18 @@ export default function GrowScene(): ReactNode {
sky.setAttribute('data-fallback-reason', reason)
}
- if (!wantsMotion || !canWebGL2) {
- showFallback(wantsMotion ? 'no-webgl2' : 'reduced-motion')
+ if (!wantsMotion || !usable) {
+ const reason = !wantsMotion ? 'reduced-motion' : gpu === 'software' ? 'software-renderer' : 'no-webgl2'
+ showFallback(reason)
if (process.env.NODE_ENV !== 'production') {
- console.info(
- '[맘편한] 3D 동네를 건너뛰고 정적 배경을 씁니다 — ' +
- (wantsMotion
- ? '이 브라우저에서 WebGL2 를 쓸 수 없습니다. chrome://gpu 를 확인하세요.'
- : '브라우저가 prefers-reduced-motion: reduce 를 보고합니다. ' +
- 'Windows: 설정 > 접근성 > 시각 효과 > 애니메이션 효과 / macOS: 손쉬운 사용 > 디스플레이 > 동작 줄이기'),
- )
+ const why: Record = {
+ 'reduced-motion':
+ '브라우저가 prefers-reduced-motion: reduce 를 보고합니다. ' +
+ 'Windows: 설정 > 접근성 > 시각 효과 > 애니메이션 효과 / macOS: 손쉬운 사용 > 디스플레이 > 동작 줄이기',
+ 'software-renderer': 'GPU 가속 없이 소프트웨어 렌더러로 돌고 있습니다. 확인만 하려면 ?town=force',
+ 'no-webgl2': '이 브라우저에서 WebGL2 를 쓸 수 없습니다. chrome://gpu 를 확인하세요.',
+ }
+ console.info(`[맘편한] 3D 동네를 건너뛰고 정적 배경을 씁니다 — ${why[reason]}`)
}
} else {
;(async () => {