Files
Pier_v1/랜딩페이지/sample-map.ts
T

46 lines
7.0 KiB
TypeScript

import { sampleProjects } from './sample-projects';
import { sampleLocations } from './sample-locations';
import { mercator, SAMPLE_MAP_CONFIG as C } from './sample-map-model';
export function createSampleMap(panel:HTMLElement,onSelect:(id:number)=>void,getSelected:()=>number,isBusy:()=>boolean){
const map=panel.querySelector<HTMLElement>('.sample-map')!,tiles=map.querySelector<HTMLElement>('.sample-map-tiles')!,markers=map.querySelector<HTMLElement>('.sample-map-markers')!,error=map.querySelector<HTMLElement>('.sample-map-error')!;
let mode='street',zoom=C.initialZoom,centre=mercator(...C.initialCentre),hover:number|null=null,animation=0,renderFrame=0,flying=false;
const max=()=>mode==='street'?C.streetMaxZoom:C.satelliteMaxZoom;
const draw=()=>{
renderFrame=0;const w=map.clientWidth,h=map.clientHeight;if(!w||!h)return;zoom=Math.min(max(),Math.max(C.minZoom,zoom));map.dataset.zoom=zoom.toFixed(2);map.dataset.maxZoom=String(max());
const scale=2**zoom,z=Math.min(max(),Math.ceil(zoom)),ratio=scale/2**z,tile=256*ratio,cx=centre[0]*scale,cy=centre[1]*scale,x0=Math.floor((cx-w/2)/tile),y0=Math.floor((cy-h/2)/tile),used=new Set<string>();
if(flying){for(const img of tiles.querySelectorAll<HTMLImageElement>('img')){const [,oldZ,oldX,oldY]=img.dataset.tile!.split('-').map((v,i)=>i?Number(v):v) as [string,number,number,number];const t=256*scale/2**oldZ;img.style.cssText=`left:${oldX*t-cx+w/2}px;top:${oldY*t-cy+h/2}px;width:${t+.3}px;height:${t+.3}px`;}}
else{
for(let x=x0;x*tile<cx+w/2;x++)for(let y=y0;y*tile<cy+h/2;y++){
if(x<0||y<0||x>=2**z||y>=2**z)continue;const key=`${mode}-${z}-${x}-${y}`;used.add(key);let img=tiles.querySelector<HTMLImageElement>(`[data-tile="${key}"]`);
if(!img){img=new Image();img.dataset.tile=key;img.alt='';img.draggable=false;img.src=mode==='street'?`https://tile.openstreetmap.org/${z}/${x}/${y}.png`:`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`;img.onerror=()=>{if(img?.isConnected){error.hidden=false;error.textContent=z>18?'이 지역의 고배율 타일을 표시할 수 없습니다. 축소해 주세요.':'지도 연결 실패 · 좌표 마커 표시';}};img.onload=()=>{error.hidden=true;};tiles.append(img);}
img.style.cssText=`left:${x*tile-cx+w/2}px;top:${y*tile-cy+h/2}px;width:${tile+.3}px;height:${tile+.3}px`;
}for(const image of tiles.querySelectorAll<HTMLImageElement>('img'))if(!used.has(image.dataset.tile!))image.remove();
}
panel.querySelector<HTMLElement>('.sample-map-attribution')!.innerHTML=mode==='street'?'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>':'Tiles © Esri · Vantor, Earthstar Geographics';
const points=sampleLocations.filter(l=>l.lon!==null&&l.lat!==null).map(l=>{const p=mercator(l.lon!,l.lat!);return{id:l.sample,x:(p[0]-centre[0])*scale+w/2,y:(p[1]-centre[1])*scale+h/2};});
markers.replaceChildren();const placed:[number,number][]=[];const lines=document.createElementNS('http://www.w3.org/2000/svg','svg');lines.style.cssText='position:absolute;inset:0;width:100%;height:100%;pointer-events:none';markers.append(lines);
for(const point of points){
let x=point.x,y=point.y;if(x>=0&&y>=0&&x<=w&&y<=h){for(let i=0;placed.some(p=>Math.hypot(p[0]-x,p[1]-y)<29)&&i<100;i++){const r=12+i*.85;x=point.x+Math.cos(i*2.39996)*r;y=point.y+Math.sin(i*2.39996)*r;}placed.push([x,y]);}
if(Math.hypot(x-point.x,y-point.y)>1){const line=document.createElementNS('http://www.w3.org/2000/svg','line');for(const [k,v] of Object.entries({x1:point.x,y1:point.y,x2:x,y2:y}))line.setAttribute(k,String(v));line.setAttribute('stroke','#538da9');line.setAttribute('stroke-width','1');lines.append(line);}
const b=document.createElement('button'),sample=sampleProjects.find(s=>s.sample===point.id)!;b.type='button';b.className='sample-marker';b.dataset.members=String(point.id);b.dataset.marker=String(point.id);b.style.left=`${x}px`;b.style.top=`${y}px`;b.textContent=String(point.id).padStart(2,'0');b.setAttribute('aria-label',`${sample.bridge} 선택`);b.title=sample.bridge;b.disabled=isBusy();b.addEventListener('click',()=>{if(!isBusy())onSelect(point.id);});markers.append(b);
}highlight();
};
const schedule=()=>{if(!renderFrame)renderFrame=requestAnimationFrame(draw);};
const highlight=()=>{markers.querySelectorAll<HTMLElement>('[data-members]').forEach(m=>{const ids=m.dataset.members!.split(',').map(Number);m.classList.toggle('is-selected',ids.includes(getSelected()));m.classList.toggle('is-hovered',hover!==null&&ids.includes(hover));});};
const stop=()=>{cancelAnimationFrame(animation);flying=false;};
const fly=(next:[number,number],targetZoom:number)=>{stop();flying=true;const from=[...centre] as [number,number],startZoom=zoom,start=performance.now();targetZoom=Math.min(max(),targetZoom);const tick=(now:number)=>{const t=Math.min(1,(now-start)/450),k=1-(1-t)**3;centre=[from[0]+(next[0]-from[0])*k,from[1]+(next[1]-from[1])*k];zoom=startZoom+(targetZoom-startZoom)*k;schedule();if(t<1)animation=requestAnimationFrame(tick);else{flying=false;schedule();}};animation=requestAnimationFrame(tick);};
const focus=(id:number,detail=false)=>{
const l=sampleLocations.find(l=>l.sample===id);if(!l||l.lon===null||l.lat===null)return;const selected=mercator(l.lon,l.lat);
fly(selected,detail?C.locationZoom:C.selectionZoom);
};
const zoomAt=(delta:number,x=map.clientWidth/2,y=map.clientHeight/2)=>{stop();const next=Math.max(C.minZoom,Math.min(max(),zoom+delta)),oldScale=2**zoom,newScale=2**next;centre=[centre[0]+(x-map.clientWidth/2)*(1/oldScale-1/newScale),centre[1]+(y-map.clientHeight/2)*(1/oldScale-1/newScale)];zoom=next;schedule();};
panel.querySelectorAll<HTMLButtonElement>('[data-map-mode]').forEach(b=>b.addEventListener('click',()=>{mode=b.dataset.mapMode!;zoom=Math.min(zoom,max());panel.querySelectorAll<HTMLButtonElement>('[data-map-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mapMode===mode)));stop();schedule();}));
panel.querySelectorAll<HTMLButtonElement>('[data-zoom]').forEach(b=>b.addEventListener('click',()=>{const d=Number(b.dataset.zoom);if(d)zoomAt(d);else fly(mercator(...C.initialCentre),C.initialZoom);}));
map.addEventListener('wheel',e=>{e.preventDefault();const r=map.getBoundingClientRect();zoomAt(e.deltaY<0?.5:-.5,e.clientX-r.left,e.clientY-r.top);},{passive:false});
map.addEventListener('dblclick',e=>{if((e.target as Element).closest('button'))return;const r=map.getBoundingClientRect();zoomAt(1,e.clientX-r.left,e.clientY-r.top);});
let drag:{x:number;y:number;c:[number,number]}|null=null;
map.addEventListener('pointerdown',e=>{if((e.target as Element).closest('button,a'))return;stop();drag={x:e.clientX,y:e.clientY,c:[...centre]};map.setPointerCapture(e.pointerId);});map.addEventListener('pointermove',e=>{if(!drag)return;centre=[drag.c[0]-(e.clientX-drag.x)/2**zoom,drag.c[1]-(e.clientY-drag.y)/2**zoom];schedule();});map.addEventListener('pointerup',()=>{drag=null;});map.addEventListener('pointercancel',()=>{drag=null;});
new ResizeObserver(schedule).observe(map);schedule();
return {focus,refresh:schedule,highlight,hover:(id:number|null)=>{hover=id;highlight();}};
}