document.addEventListener("DOMContentLoaded", async function () { initMap(); await initLayer(); initView(); // initMapLegend(); mouseEvent(); }); let map; let originalFetch = window.fetch; async function newFetch(url, options = {}) { let headers = options.headers || { 'user-agent': "Mozilla/5.0 (Linux; Android 12; SM-N976N Build/SP1A.210812.016; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.133 Mobile Safari/537.36 korailtalk AppVersion/6.3.3", 'x-requested-with': "com.korail.talk", 'sec-ch-ua-platform': "Android", "referer": "https://gis.korail.com/korailTalk/entrance" }; let f = originalFetch('https://proxy.devpg.net', { headers: { 'X-Proxy-URL': 'https://gis.korail.com' + url, 'X-Proxy-Header': JSON.stringify(headers) } }); console.log(`fetch(https://gis.korail.com${url}, headers=${JSON.stringify(headers)}`, f); return f } window.fetch = newFetch; function initMap() { map = new ol.Map({ target: 'map', view: new ol.View({ minZoom: 5, maxZoom: 15.9, extent: [120, 30, 135, 45].toEPSG3857(), }), controls: ol.control.defaults.defaults({ attribution: false, zoom: false, rotate: false, }), interactions: ol.interaction.defaults.defaults({ altShiftDragRotate: false, pinchRotate: false, }).extend([new ol.interaction.DblClickDragZoom()]) }); if (params.lon && params.lat) { map.getView().setCenter([params.lon, params.lat].toEPSG3857()); map.getView().setZoom(11); } else { map.getView().setCenter([127.35, 36.50].toEPSG3857()); map.getView().setZoom(6); } map.getTargetElement().style.background = '#b5dbf3'; } const trainColors = { ktx: '#1B4298', itx: '#C10230', etc: '#54565A', srt:'#651C5C' } Object.freeze(trainColors); const layerStyles = { line: { 'stroke-width': 2.25, 'stroke-color': '#68a7d5', }, station: { 'circle-radius': [ 'match', ['get', 'grade', 'string'], '0', ["interpolate", ["linear"],["zoom"], 5, 3, 15, 6], '1', ["interpolate", ["linear"],["zoom"], 5, 3, 15, 6], '2', ["interpolate", ["linear"],["zoom"], 5, 2, 15, 5], '3', ["interpolate", ["linear"],["zoom"], 5, 2, 15, 5], 0 ], 'circle-stroke-width': [ 'match', ['get', 'grade', 'string'], '0', ["interpolate", ["linear"],["zoom"], 5, 1, 15, 3], '1', ["interpolate", ["linear"],["zoom"], 5, 1, 15, 3], '2', ["interpolate", ["linear"],["zoom"], 5, 1, 15, 3], '3', 0, 0 ], 'circle-stroke-color': '#68a7d5', 'circle-fill-color': [ 'match', ['get', 'grade', 'string'], '3', '#68a7d5', 'white' ] }, station_label: { 'text-value': ['get', 'name', 'string'], 'text-stroke-color': "#68a7d5", 'text-stroke-width': 3, 'text-fill-color': "#ffffff", 'text-font': [ 'match', ['get', 'grade', 'number'], 0, '17px SUIT-M', 1, '15px SUIT-M', 2, '13px SUIT-M', 3, '11px SUIT-M', '0px SUIT-M' ], 'text-offset-x': ['get', 'text_offset_x'], 'text-offset-y': ['get', 'text_offset_y'], }, trainStyle: (feature, resolution) => { const { trn_no: trnNo, trn_case: trnCase, trn_opr_cd: trnOpr, trn_clsf: trnClass, icon, bearing } = feature.getProperties(); const coords = feature.getGeometry().getCoordinates() const overlabs = map.getLayer('train').getSource().getFeaturesAtCoordinate(coords).sort((a, b) => a.get('trn_no') - b.get('trn_no')); return new ol.style.Style({ image: new ol.style.Icon({ src: `icon/train/ic_${icon}.svg`, rotation: bearing, scale: 0.6 + ((map.getView().getZoomForResolution(resolution) - 10) / 10), declutterMode: 'none', }), text: (map.getView().getZoom() >= 11) ? new ol.style.Text({ font: '14px SUIT-B', stroke: new ol.style.Stroke({ color: '#f3f3f3', width: 1, }), fill: new ol.style.Fill({ color: trainColors[trnClass], }), offsetX: 5 + map.getView().getZoom(), offsetY: 0 + overlabs.indexOf(feature) * 20, overflow: true, declutterMode: 'none', textAlign: 'left', textBaseline: 'middle', text: `${trnCase} ${trnNo}`, }) : null, zIndex: map.get('trainClicked')?.getId() == feature.getId() ? Infinity : trnOpr == 15 ? overlabs.reverse().indexOf(feature) : 0 }) } }; Object.freeze(layerStyles); async function initLayer() { try { map.startLoadingEffect(); await Promise.all([ map.addBaseTileLayer('korean'), initRailLayers(), initStationLayers(), initTrainLayer(), ]); } catch { } finally { map.finishLoadingEffect(); } } async function initRailLayers() { return new Promise(async (resolve) => { await Promise.all([ // 고속선 정보 map.addRailLayer('/api/data/rail_ktx', { name: 'rail_ktx', style: layerStyles.line, zIndex: 1, }), // 준고속선 정보 map.addRailLayer('/api/data/rail_semi', { name: 'rail_semi', style: layerStyles.line, zIndex: 1, }), // 일반선 정보 map.addRailLayer('/api/data/rail_normal', { name: 'rail_normal', style: layerStyles.line, zIndex: 1, }), // 물류선(추가) 정보 map.addRailLayer('/api/data/rail_logis', { name: 'rail_logis', style: layerStyles.line, zIndex: 1, }), ]); resolve(); }) } async function initStationLayers() { const response = await fetch('/api/data/station'); map.set('korailStation', Object.freeze(await response.json())); const filteredStation = (binary) => { const temp = JSON.parse(JSON.stringify(map.get('korailStation'))); temp.features = temp.features.filter((f) => (f.properties.text_offset_x && f.properties.text_offset_y) !== null); temp.features = temp.features.filter((f) => Number(f.properties.shown_layer) & binary); return ol.source.Vector.fromGeoJSON(temp); } return new Promise(async (resolve) => { await Promise.all([ // 고속역 정보 map.addPointsLayer({ name: 'KTX_Station', source: filteredStation(0b100000), style: layerStyles.station, labelStyleFunction: layerStyles.station_label, zIndex: 2, }), // 준고속역 정보 map.addPointsLayer({ name: 'SemiExpress_Station', source: filteredStation(0b010000), style: layerStyles.station, labelStyleFunction: layerStyles.station_label, zIndex: 2, }), // 일반역 정보 map.addPointsLayer({ name: 'Normal_Station', source: filteredStation(0b001000), style: layerStyles.station, labelStyleFunction: layerStyles.station_label, zIndex: 2, }), // 여객역 정보 map.addPointsLayer({ name: 'Passenger_Station', source: filteredStation(0b000100), style: layerStyles.station, labelStyleFunction: layerStyles.station_label, zIndex: 2, }), // 물류역 정보 map.addPointsLayer({ name: 'Dist_Station', source: filteredStation(0b000001), style: layerStyles.station, labelStyleFunction: layerStyles.station_label, zIndex: 2, }), ]); resolve(); }); } function initRefreshDOM() { const dom = document.createElement('div') dom.id = 'refresh-info' dom.style.position = 'absolute' dom.style.bottom = '0px' dom.style.right = '0px' dom.style.display = 'flex' dom.style.flexDirection = 'column' dom.style.justifyContent = 'space-evenly' dom.style.alignItems = 'end' dom.style.margin = '20px' dom.style.padding = 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)' dom.style.boxSizing = 'border-box' dom.style.touchAction = 'none' const button = document.createElement('input') button.type = 'image' button.src = 'icon/refresh.svg' button.alt = '새로고침' button.onclick = (event) => { refreshTrainLayer() const target = event.currentTarget target.classList.add('rotate') setTimeout(() => { target.classList.remove('rotate'); }, 500); } button.style.width = '1rem' button.style.height = '1rem' button.style.background = 'transparent' button.style.border = '0px' button.style.borderRadius = '50%' button.style.boxShadow = '0px 0px 5px gainsboro' button.style.background = 'white' button.style.margin = '5px 0px' button.style.padding = '5px' const text = document.createElement('span') text.style.fontSize = 'small' text.style.transition = 'color 0.5s' text.style.color = 'black' dom.appendChild(button) dom.appendChild(text) map.getTargetElement().appendChild(dom) updateRefreshText(); } const refreshIterator = { interval: undefined, function: undefined, } function syncIterator() { const zoom = map.getView().getZoom(); const intZoom = Math.floor(zoom); const intervalSec = 60 - ((intZoom - 5) * 5.5); refreshIterator.interval = intervalSec * 1000; clearInterval(refreshIterator.function); refreshIterator.function = setInterval(refreshTrainLayer, refreshIterator.interval); } function updateRefreshText() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); // 월은 0부터 시작하므로 +1 필요 const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); const text = document.querySelector('div#refresh-info > span') text.textContent = `(${year}.${month}.${day}. ${hours}:${minutes}:${seconds}) 기준`; text.classList.add('blink') setTimeout(() => { text.classList.remove('blink'); }, 500); } async function refreshTrainLayer() { const getBufferedExtent = (view) => { const extent = view.calculateExtent(); const resolution = view.getResolution(); const buffer = 100 * resolution; const bufferedExtent = [ extent[0] - buffer, extent[1] - buffer, extent[2] + buffer, extent[3] + buffer, ] return bufferedExtent; } return new Promise(async (resolve) => { const bufferedExtent = getBufferedExtent(map.getView()); const bbox = bufferedExtent.toWGS84().join(','); const filter = `${params.trn ? `trnNo=${params.trn}` : `bbox=${bbox}`}`; if (params.trn && params.date) { const date = params.date const now = new Date() const yyyy = now.getFullYear() const mm = (now.getMonth() +1).toString().padStart(2, '0') const dd = now.getDate().toString().padStart(2, '0') const yyyymmdd = `${yyyy}${mm}${dd}`; if (date !== yyyymmdd) { return } } const response = await fetch(`/api/train?${filter}`); const geojson = await response.json(); const newFeatures = ol.source.Vector.fromGeoJSON(geojson).getFeatures(); if (params?.trn) { newFeatures.forEach((f) => { f.set('icon', 'train'); f.set('bearing', 0); }) } else { newFeatures.forEach((f) => { f.set('icon', `${f.get('trn_clsf')}${(f.get('delay') > 20 ? '_delay' : '')}`); }) } const trainSource = map.getLayer('train')?.getSource(); trainSource?.clear(); trainSource?.addFeatures(newFeatures); if (map.get('trainClicked') && map.isShowingOverlay()) { const past = map.get('trainClicked'); const now = newFeatures.find((feature) => (feature.get('trn_no') === past.get('trn_no'))); if (now) { const [pastX, pastY] = past.getGeometry().getCoordinates(); const nowCoords = now.getGeometry().getCoordinates(); const [nowX, nowY] = nowCoords; if ( !(pastX === nowX && pastY === nowY) ) { map.getLayer('train').once('postrender',() => { map.getOverlay().setPosition(nowCoords); map.getView().animate({ center: nowCoords, duration: 500, }); showPopup(now); }) } } else { map.hideOverlay(); } } //sendMessageToApp(); resolve() }) .then(updateRefreshText) } function reloadTrainLayer() { refreshTrainLayer() syncIterator() } async function initTrainLayer() { return new Promise(async (resolve) => { map.addPointsLayer({ name: 'train', style: layerStyles.trainStyle, zIndex: 9, declutter: true, }); map.on('pointerdrag', map.hideOverlay) map.on('moveend', reloadTrainLayer) initRefreshDOM(); resolve() }); } const showPopup = (feature) => { return new Promise(async (resolve, reject) => { map.set('trainClicked', feature); const coordinate = feature.getGeometry().getFlatCoordinates(); const popup = map.getOverlay(); try { const { trn_no, trn_case, trn_clsf, dpt_stn_nm, dpt_pln_dttm, arv_stn_nm, arv_pln_dttm, now_stn, next_stn, delay } = feature.getProperties(); const dpt_time = `${dpt_pln_dttm.slice(8,10)}:${dpt_pln_dttm.slice(10,12)}`; const arv_time = `${arv_pln_dttm.slice(8,10)}:${arv_pln_dttm.slice(10,12)}`; const now_loc = now_stn && next_stn ? `${now_stn}${next_stn}` : ''; html = `
${trn_case} ${trn_no}

운행구간${dpt_stn_nm}(${dpt_time}) ~ ${arv_stn_nm}(${arv_time})
현재위치${now_loc}
예상지연${delay} 분
`; popup.element.innerHTML = html; if (delay === null) { popup.element.querySelector('div[usage="delay"]')?.remove() popup.element.querySelector('div[usage="delay-reason"]')?.remove() } popup.setPosition(coordinate); map.getView().animate({ center: coordinate, duration: 500, }) map.showOverlay(); popup.element.style.top = `-${popup.element.offsetHeight + 20}px`; popup.element.style.left = `-${popup.element.offsetWidth / 2}px`; resolve(); } catch { map.hideOverlay(); reject(); } }); } function mouseEvent() { map.set('trainClicked', null); const options = { layerFilter: (layer) => layer.get('name') === 'train', hitTolerance: 20 }; map.on('singleclick', async (e) => { if (map.getView().getInteracting() || map.getView().getAnimating()) { map.getTargetElement().style.cursor = "grabbing"; return; } if (map.get('trainClicked') && map.getFeaturesAtPixel(e.pixel, options).length > 0 && map.get('trainClicked') === map.getFeaturesAtPixel(e.pixel, options)[0]) { return; } if (map.get('trainClicked') !== null) { map.set('trainClicked', null); map.hideOverlay(); } const features = map.getFeaturesAtPixel(e.pixel, options); if (features.length > 0 && showPopup(features[0])) { map.showOverlay(); } else { map.hideOverlay(); } }); } function createLegend() { const controllerDOM = document.createElement('div'); controllerDOM.id = 'viewController' controllerDOM.style.position = 'absolute'; controllerDOM.style.width = '100%'; controllerDOM.style.bottom = '0px'; controllerDOM.style.minWidth = '100px'; controllerDOM.style.display = 'flex'; controllerDOM.style.flexDirection = 'row'; controllerDOM.style.justifyContent = 'space-evenly'; controllerDOM.style.marginBottom = '30px'; controllerDOM.style.touchAction = 'none'; const buttons = ["고속", "일반", "광역", "화물"] buttons.forEach((name, index) => { const buttonSize = '50px'; const button = document.createElement('div'); button.style.display = 'block'; button.style.justifyContent = 'space-between'; button.style.width = buttonSize; button.style.height = buttonSize button.style.lineHeight = buttonSize; button.style.borderRadius = buttonSize; button.style.background = 'darkgray'; button.style.color = 'white'; button.style.textAlign = 'center'; button.style.boxShadow = '1px 1px 5px gray'; button.textContent = name; button.addEventListener('click', () => { map.set('viewType', index); viewcontrol(map.get('viewType')); }) controllerDOM.appendChild(button); }); return controllerDOM; } function initView() { const view = map.getView(); if (params?.trn && params?.date) { map.once('rendercomplete', () => { const trains = map.getLayer('train').getSource().getFeatures(); const myTrain = trains.find((t) => t.get('trn_no') == params.trn); if (myTrain) { showPopup(myTrain); if (!(params.lon && params.lat)) { view.setCenter(myTrain.getGeometry().flatCoordinates) view.setZoom(11); } } else { // createModalPopup('열차가 운행중이지 않습니다.'); setTimeout(() => window.location.reload(), 60000) } }); } } function createModalPopup(_content) { const backgroundDOM = document.createElement('div'); backgroundDOM.style.position = 'absolute'; backgroundDOM.style.display = 'flex'; backgroundDOM.style.width = '100%'; backgroundDOM.style.height = '100%'; backgroundDOM.style.background = 'rgba(0,0,0,0.5)'; backgroundDOM.style.userSelect = 'none'; backgroundDOM.id = 'modal-popup'; const popupDOM = document.createElement('div'); popupDOM.style.margin = 'auto'; popupDOM.style.padding = '10px'; popupDOM.style.borderRadius = '5px'; popupDOM.style.background = 'white'; popupDOM.style.textAlign = 'center'; //popupDOM.style.zIndex = 1; popupDOM.innerText = _content; backgroundDOM.appendChild(popupDOM); document.body.appendChild(backgroundDOM); } const webviewPlatform = Object.freeze({ IOS_WEBVIEW: "iOS WebView", IOS_BROWSER: "iOS Browser", AOS_WEBVIEW: "Android WebView", AOS_BROWSER: "Android Browser", UNKNOWN: "Unknown Platform" }); function getWebViewPlatform() { var userAgent = navigator.userAgent || navigator.vendor || window.opera; if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) { if (window.webkit && window.webkit.messageHandlers) { return webviewPlatform.IOS_WEBVIEW; } return webviewPlatform.IOS_BROWSER; } if (/android/i.test(userAgent)) { if (window.Android) { return webviewPlatform.AOS_WEBVIEW; } return webviewPlatform.AOS_BROWSER; } return webviewPlatform.UNKNOWN; } function sendMessageToApp() { const osPlatform = getWebViewPlatform() const message = "gis refresh" switch (osPlatform) { case webviewPlatform.IOS_WEBVIEW : case webviewPlatform.IOS_BROWSER : window.webkit.messageHandlers.refresh.postMessage(message) return; case webviewPlatform.AOS_WEBVIEW : case webviewPlatform.AOS_BROWSER : Android.showMessage(message); return; default : return; } }