diff --git a/README.md b/README.md index e74ace5..077e79b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ npm run build # out/ 에 정적 파일 생성 | 경로 | 내용 | |------|------| -| `/` | 개요. 월령 축을 옮기면 지원금이 유효·소급가능·지남으로 갈립니다 | +| `/` | 여섯 해. 스크롤이 아이의 개월 수(0 → 72)이고, 뒤의 3D 동네가 그에 맞춰 자랍니다 | | `/benefits` | 자격 판정, 수령액 계산, 금액 신뢰도 | | `/facilities` | 정원 관측, 빈자리 알림, 대기 기간 통계 | | `/data` | 출처 네 곳과 **아는 것 / 모르는 것** 대조 | @@ -59,9 +59,66 @@ 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 미지원, **GPU 가속 없는 소프트웨어 렌더러**(SwiftShader · llvmpipe)면 three.js 청크를 +import 하기 **전에** 판정하고 정적 배경을 띄웁니다. 소프트웨어 렌더러에서는 이 장면이 초당 몇 +프레임이라 메인 스레드를 잡아먹고 스크롤까지 끊깁니다 — CI 의 헤드리스 Chrome 에서 페이지 로드가 +60초를 넘긴 것도 이 때문이었습니다. 카피는 전부 DOM 이라 3D 가 없어도 페이지는 온전합니다. + +GPU 없는 환경에서 장면을 확인만 하려면 주소에 `?town=force` 를 붙입니다. + +> 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 +169,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 동네, 지연 로드) diff --git a/package-lock.json b/package-lock.json index 963a2dc..d800e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "15.3.5", "react": "19.0.0", - "react-dom": "19.0.0" + "react-dom": "19.0.0", + "three": "^0.186.0" }, "devDependencies": { "@eslint/compat": "^1.4.1", @@ -20,6 +21,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.186.0", "eslint": "^9.39.5", "eslint-config-next": "^15.3.5", "puppeteer": "^25.5.0", @@ -42,6 +44,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1511,6 +1520,13 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1573,6 +1589,35 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", @@ -4037,6 +4082,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5586,6 +5638,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7203,6 +7262,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index d734bb3..31f02f3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dependencies": { "next": "15.3.5", "react": "19.0.0", - "react-dom": "19.0.0" + "react-dom": "19.0.0", + "three": "^0.186.0" }, "devDependencies": { "@eslint/compat": "^1.4.1", @@ -25,6 +26,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.186.0", "eslint": "^9.39.5", "eslint-config-next": "^15.3.5", "puppeteer": "^25.5.0", 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 {