Files
Pier_v1/랜딩페이지/landing-motion.ts
T
ypshimandClaude Haiku 5.5 fc520ec03d feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:02 +09:00

82 lines
4.6 KiB
TypeScript

import { heroCapture } from './assets/okseong-hero-capture';
const flowPath = (() => {
let connected = false;
return heroCapture.terrainFlow.map(point => {
if (point === null) { connected = false; return ''; }
const command = connected ? 'L' : 'M'; connected = true;
return `${command}${point.x} ${point.y}`;
}).join(' ');
})();
const frame = {
left: Math.min(...heroCapture.points.map(p=>p.x))-50,
right: Math.max(...heroCapture.points.map(p=>p.x))+50,
top: Math.min(...heroCapture.points.map(p=>p.y))-60,
bottom: Math.max(...heroCapture.points.map(p=>p.y))+70,
};
/** Markers share the exact camera projection used to capture the model. */
export const heroMotionMarkup = (): string => `
<svg class="landing-model-motion" viewBox="0 0 ${heroCapture.width} ${heroCapture.height}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<filter id="landing-energy-glow" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="3"/></filter>
<linearGradient id="landing-flow-fade"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset=".12" stop-color="#ffffff"/><stop offset=".88" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
<path id="landing-alignment-path" pathLength="1000" d="${flowPath}"/>
</defs>
<use href="#landing-alignment-path" class="landing-energy-track"/>
<use href="#landing-alignment-path" class="landing-energy-haze" filter="url(#landing-energy-glow)"/>
<use href="#landing-alignment-path" class="landing-energy-flow"/>
${heroCapture.points.map(({name,x,y},i)=>`
<g class="landing-pier-beacon" data-structure="${name}" style="--beacon-delay:${(i*.72+1.6).toFixed(2)}s" transform="translate(${x} ${y})">
<line x1="0" y1="-31" x2="0" y2="-8"/>
<circle class="landing-pier-pulse" r="11"/><circle class="landing-pier-point" r="3.5"/>
</g>`).join('')}
<path class="landing-hud-bracket" d="M${frame.left} ${frame.top}h-24v22 M${frame.right} ${frame.top}h24v22 M${frame.left} ${frame.bottom}h-24v-22 M${frame.right} ${frame.bottom}h24v-22"/>
</svg>`;
export function createHeroMotion(root: HTMLElement): void {
const hero = root.querySelector<HTMLElement>('.landing-hero');
if (!hero) return;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const finePointer = matchMedia('(hover: hover) and (pointer: fine)');
let frame = 0;
let currentX = 0;
let currentY = 0;
let targetX = 0;
let targetY = 0;
const update = (): void => {
currentX += (targetX-currentX)*.075;
currentY += (targetY-currentY)*.075;
hero.style.setProperty('--landing-parallax-x', `${currentX.toFixed(2)}px`);
hero.style.setProperty('--landing-parallax-y', `${currentY.toFixed(2)}px`);
frame = Math.abs(targetX-currentX)+Math.abs(targetY-currentY)>.08 ? requestAnimationFrame(update) : 0;
};
const reset = (): void => {
targetX = targetY = currentX = currentY = 0;
cancelAnimationFrame(frame);frame=0;
hero.style.setProperty('--landing-parallax-x','0px');hero.style.setProperty('--landing-parallax-y','0px');
};
hero.addEventListener('pointermove', (event) => {
if (reduced.matches || !finePointer.matches || root.hidden || root.classList.contains('guide-open')) return;
const bounds=hero.getBoundingClientRect();
targetX=((event.clientX-bounds.left)/bounds.width-.5)*18;
targetY=((event.clientY-bounds.top)/bounds.height-.5)*12;
if (!frame) frame=requestAnimationFrame(update);
},{passive:true});
hero.addEventListener('pointerleave',reset,{passive:true});
reduced.addEventListener('change',()=>{if(reduced.matches)reset();});
document.addEventListener('visibilitychange',()=>{root.classList.toggle('landing-paused',document.hidden);if(document.hidden)reset();});
// Native import dialogs are outside the overlay. Pause all ambient work while
// an upload/guide panel is active or when the main application is showing.
const synchronize = (): void => {
const blocked=!!root.hidden||!!document.querySelector('#import-dialog[open]')||!!root.querySelector('.landing-panel.visible')||root.classList.contains('guide-open');
if (root.classList.contains('landing-inactive')!==blocked) root.classList.toggle('landing-inactive',blocked);
if (blocked)reset();
};
const observer=new MutationObserver(synchronize);
observer.observe(root,{attributes:true,subtree:true,attributeFilter:['hidden','class']});
const importDialog=document.querySelector('#import-dialog');
if(importDialog)observer.observe(importDialog,{attributes:true,attributeFilter:['open']});
synchronize();
}