공용 파일(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>
82 lines
4.6 KiB
TypeScript
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();
|
|
}
|