diff --git a/config.js b/config.js index 2797701..a0beadf 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,6 @@ 'use strict'; import {Color} from './js/colorlib.js'; +import {SpriteBounds} from './js/spritelib.js'; export const gradeColors = { grade_flat: '#fff', @@ -39,4 +40,55 @@ export const sidingUsageMeanings = { LP:'Passenger Transfer', SP:'Passenger Car Storage', D:'Service & Repair', +} + +export const bezierGradeResolution = 80; +export const bezierLengthResolution = 80; +export const bezierCurvatureResolution = 80; +export const scrollZoomFactor = 1.25; +export const buttonZoomFactor = 1.5; + +export const minZoomLevel = 0.01; +export const maxZoomLevel = 60; +export const defaultMapPadding = 500; + +export const maxZoneLength = 700; + +/** How many pixels around the view is still considered "visible", beyond this and items are culled. */ +export const viewCullMargin = 60; + +export const tooltipHitzone = { + default: {radius:12}, + junction: {width:24, height:35}, +}; + +export const spriteBounds = { + unknown: new SpriteBounds(0,0,20,20), + junction: new SpriteBounds(0,20,20,20), + gradeArrow: new SpriteBounds(20,20,20,20), + gradeArrowDown: new SpriteBounds(40,20,20,20), + speed_1: new SpriteBounds(20,40,20,20), + speed_2: new SpriteBounds(40,40,20,20), + speed_3: new SpriteBounds(60,40,20,20), + speed_4: new SpriteBounds(20,60,20,20), + speed_5: new SpriteBounds(40,60,20,20), + speed_6: new SpriteBounds(60,60,20,20), + speed_7: new SpriteBounds(80,60,20,20), + speed_8: new SpriteBounds(100,60,20,20), + speed_9: new SpriteBounds(120,60,20,20), + speed_10: new SpriteBounds(140,60,20,20), + speed_12: new SpriteBounds(160,60,20,20), + office: new SpriteBounds(0,80,20,20), + office_military: new SpriteBounds(20,80,20,20), + office_museum: new SpriteBounds(40,80,20,20), + water: new SpriteBounds(0,100,20,20), + coal: new SpriteBounds(20,100,20,20), + landmark: new SpriteBounds(40,100,20,20), + shop: new SpriteBounds(60,100,20,20), + garage: new SpriteBounds(80,100,20,20), + demonstrator: new SpriteBounds(100,100,20,20), + service: new SpriteBounds(0,120,20,20), + service_repair: new SpriteBounds(20,120,20,20), + service_diesel: new SpriteBounds(40,120,20,20), + service_charger: new SpriteBounds(60,120,20,20), } \ No newline at end of file diff --git a/index.html b/index.html index d2809cf..cd96045 100644 --- a/index.html +++ b/index.html @@ -12,218 +12,11 @@ -
-
- - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 2 - - - - 3 - - - - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - 9 - - - - 10 - - - - 12 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ +
@@ -254,7 +47,8 @@
Hover over tracks and objects for detailed information.
- Last Updated: + Last update : +

Key

@@ -277,5 +71,9 @@
+
+ + +
diff --git a/index.js b/index.js index 8704161..2256a14 100644 --- a/index.js +++ b/index.js @@ -5,129 +5,36 @@ import * as Bezier from './js/bezierlib.js'; import * as Utils from './js/utillib.js'; import {Color} from './js/colorlib.js'; import * as Config from './config.js'; +import * as MapData from './js/mapdata.js'; +import * as Legend from './legend.js'; -// CONFIG -const bezierGradeResolution = 80; -const bezierLengthResolution = 80; -const bezierCurvatureResolution = 80; -const padding = 500; -const scrollZoomFactor = 1.25; -const buttonZoomFactor = 1.5; -const trackWidth = { - base: 110, - min: 3, - max: 90, -} -const signSize = { - base: 4, - min: 0.05, - max: 1.8 -} - -const scaleCullBaseDistance = 500; - -// Lower values make shorter track sections show their marker further out. -const scaleCullBaseSectionLength = 22; -const scaleCullBaseSectionLengthSpeedboard = 100; - -// How long a track section can go without changing before another marker is placed. Increases readability -const maxSectionLength = 700; - -// Zoom levels at which different markers get swapped in/out. -const scaleCullThresholds = [ - {scale:1}, - {scale:2}, - {scale:3}, - {scale:6}, - {scale:12}, - {scale:24}, - {scale:60}, -]; - -// CODE -const map = document.getElementById('map'); - -const map_container = document.getElementById('mapContainer'); -let map_container_rect = map_container.getBoundingClientRect(); -const resizeObserver = new ResizeObserver(() => {map_container_rect = map_container.getBoundingClientRect();}); -resizeObserver.observe(map_container); - -const map_navigator = document.getElementById('mapNavigator'); +const mapContainer = document.getElementById('mapContainer'); +const mapCanvas = document.getElementById('mapCanvas'); +/** The main map drawing context. + * @type {CanvasRenderingContext2D} */ +const mapctx = mapCanvas.getContext('2d'); +const mapSprites = document.getElementById('mapSprites'); +const mapTerrain = document.getElementById('mapTerrain'); const zoomLevelDisplay = document.getElementById('zoomLevelDisplay'); -const zoomStyle = document.getElementById('zoomStyle'); -const styleRoot = document.querySelector(':root'); -const svgns = map.getAttribute('xmlns'); -const redrawTriggerElement = document.getElementById('redrawTrigger'); -const map_rails = document.createElementNS(svgns, 'g'); -map_rails.setAttribute('id', 'map_rails'); -map.appendChild(map_rails); - -//const map_markers = document.createElementNS(svgns, 'g'); -//map_markers.setAttribute('id', 'map_markers'); -//map.appendChild(map_markers); -const map_markers = document.getElementById('mapMarkers'); -const poiLayers = { - container: document.createElementNS(svgns, 'g'), - - stations: document.createElementNS(svgns, 'g'), - service: document.createElementNS(svgns, 'g'), - features: document.createElementNS(svgns, 'g'), - landmarks: document.createElementNS(svgns, 'g') -}; - -let mapData = []; -let trackNameCounting = {}; -let poiData = []; -let tracks = []; -let yardMarkers = {}; -let markers = []; - -//let currentMapNav = {}; -let mapNav = {}; -let previousMapScale = -1; - -let MapMatrix = {}; +let isNavigating = false; document.addEventListener('DOMContentLoaded', async () => { await loadTrackData('trackdata_dv.json'); await loadPoiData('poi_dv.json'); + MapData.sortMarkers(); + + Utils.generateGradeArrows(); + + MapData.matrix.initialize(); + MapData.view.initialize(); + redrawMap(); mapNavigationSetup(); + Legend.initialize(); + tooltipSetup(); }); -async function loadTrackData(file){ - mapData = await (await fetch(new Request(file))).json(); - establishDimensions(); - fillSvg(); -} - -function filterToTrack(trackName, id=0){ - let idx = 0; - for(const track of mapData){ - if(track.name == trackName){ - if(idx == id){ - mapData = [track]; - return; - } - idx++; - } - } -} - -async function loadPoiData(file){ - poiData = await (await fetch(new Request(file))).json(); - - for(let layerIdx in poiLayers){ - poiLayers[layerIdx].setAttribute('id', 'poi_'+layerIdx); - if(layerIdx == 'container') continue; - poiLayers.container.appendChild(poiLayers[layerIdx]); - } - map_markers.appendChild(poiLayers.container); - - for(let poi of poiData){ - addPoi(poi); - } -} +window.addEventListener('resize', () => {MapData.view.dirty = true}); /** Handling for the map scrolling and zooming */ function mapNavigationSetup(){ @@ -138,7 +45,6 @@ function mapNavigationSetup(){ const touchDownHandler = e => { if(e.button != 0) return; if(touchCache.length == 0){ - map_container.style.cursor = 'grabbing'; document.addEventListener('pointermove', touchMoveHandler); document.addEventListener('pointerup', touchUpHandler); @@ -150,47 +56,52 @@ function mapNavigationSetup(){ touchCount++; if(touchCount == 2){ pinchDistance = getTouchDistance(); - previousScale = mapNav.scale; + previousScale = MapData.view.scale; } + mapContainer.style.cursor = 'grabbing'; + isNavigating = true; } const touchMoveHandler = e => { if(!touchCache[e.pointerId]) return; if(touchCount == 1){ - mapNav.x += e.clientX - touchCache[e.pointerId].x; - mapNav.y += e.clientY - touchCache[e.pointerId].y; + MapData.view.x += e.clientX - touchCache[e.pointerId].x; + MapData.view.y -= e.clientY - touchCache[e.pointerId].y; }else if(touchCount == 2){ getTouchAverage(); - zoomAtPosition(touchCenter_x, touchCenter_y, (previousScale * getTouchDistance()/pinchDistance)/mapNav.scale); + zoomAtPosition(touchCenter_x, touchCenter_y, (previousScale * getTouchDistance()/pinchDistance)/MapData.view.scale); } touchCache[e.pointerId].x = e.clientX; touchCache[e.pointerId].y = e.clientY; + MapData.view.dirty = true; } const touchUpHandler = e => { if(!touchCache[e.pointerId]) return; touchCache[e.pointerId] = null; touchCount--; - if(touchCount == 0){ - map_container.style.removeProperty('cursor'); + if(touchCount <= 0){ + mapContainer.style.cursor = ''; + isNavigating = false; } } const scrollHandler = e => { - if(e.deltaY != 0) zoomAtPosition(e.clientX, e.clientY, e.deltaY > 0 ? 1/scrollZoomFactor : scrollZoomFactor); + if(e.deltaY != 0) zoomAtPosition(e.clientX, e.clientY, e.deltaY > 0 ? 1/Config.scrollZoomFactor : Config.scrollZoomFactor); } const navButtonInput = navType => { - const centerX = map_container.offsetWidth / 2; - const centerY = map_container.offsetHeight / 2; + const centerX = mapContainer.offsetWidth / 2; + const centerY = mapContainer.offsetHeight / 2; switch(navType){ case 'zoomIn': - zoomAtPosition(centerX, centerY, buttonZoomFactor); + zoomAtPosition(centerX, centerY, Config.buttonZoomFactor); break; case 'zoomOut': - zoomAtPosition(centerX, centerY, 1/buttonZoomFactor); + zoomAtPosition(centerX, centerY, 1/Config.buttonZoomFactor); break; } } + const navUpdate = ts => { - if(ts){ - updateMapview(); + if(ts && MapData.view.dirty){ + redrawMap(); } requestAnimationFrame(navUpdate); } @@ -235,698 +146,296 @@ function mapNavigationSetup(){ let zoomCursorLocal_x = 0; let zoomCursorLocal_y = 0; const zoomAtPosition = (x, y, scaleFactor) => { - zoomCursorLocal_x = x - map_container_rect.left; - zoomCursorLocal_y = y - map_container_rect.top; - mapNav.x = zoomCursorLocal_x - scaleFactor * (zoomCursorLocal_x-mapNav.x); - mapNav.y = zoomCursorLocal_y - scaleFactor * (zoomCursorLocal_y-mapNav.y); - mapNav.scale *= scaleFactor; + if(MapData.view.scale * scaleFactor > Config.maxZoomLevel || MapData.view.scale * scaleFactor < Config.minZoomLevel){ + MapData.view.dirty = false; + return; + } + MapData.view.x = x - scaleFactor * (x - MapData.view.x); + MapData.view.y = -y - scaleFactor * (-y - MapData.view.y); + MapData.view.scale *= scaleFactor; + MapData.view.dirty = true; } - map_container.addEventListener('pointerdown', touchDownHandler); - map_container.addEventListener('wheel', scrollHandler); + mapContainer.addEventListener('pointerdown', touchDownHandler); + mapContainer.addEventListener('wheel', scrollHandler); document.getElementById('navZoomIn').addEventListener('click', e => {navButtonInput('zoomIn');}); document.getElementById('navZoomOut').addEventListener('click', e => {navButtonInput('zoomOut');}); navUpdate(); } -/** Readjust map view to focus on position */ -function updateMapview(){ - if(mapNav == null) return; - map_navigator.style.transform = `translate(${mapNav.x}px, ${mapNav.y}px) scale(${mapNav.scale})`; - if(mapNav.scale !== previousMapScale){ - zoomLevelDisplay.innerHTML = `Zoom: ${Math.round(mapNav.scale*100)/100}x`; - let dirtyScale = previousMapScale < 0; - for(const cullThreshold of scaleCullThresholds){ - if(dirtyScale || (previousMapScale < cullThreshold.scale && mapNav.scale > cullThreshold.scale) || (previousMapScale > cullThreshold.scale && mapNav.scale < cullThreshold.scale)){ - dirtyScale = true; - break; - } - } - map_rails.setAttribute('stroke-width', Utils.clamp(trackWidth.base/mapNav.scale, trackWidth.min, trackWidth.max)); - styleRoot.style.setProperty('--markerScale', 1/mapNav.scale); - if(dirtyScale){ - let dynamicStyles = []; - for(let i=0; i= scaleCullThresholds[i].scale) dynamicStyles.push(`.maxZoomCull_${i}{display:none;}`); - if(mapNav.scale <= scaleCullThresholds[i].scale) dynamicStyles.push(`.minZoomCull_${i}{display:none;}`); - } - zoomStyle.innerHTML = dynamicStyles.join('\n'); - } - previousMapScale = mapNav.scale; - forceRedraw(); - } -} +function tooltipSetup(){ + const tooltip = document.getElementById('tooltip'); + let tooltipX = 0; + let tooltipY = 0; + let tooltipDirty = false; + let removeTooltip = false; -function forceRedraw(){ - redrawTriggerElement.style.backgroundColor = Color.random().hex; -} - -/** Get the bounding box of all the tracks, and set the map bounds accordingly. Also flips the Y axis. - * **Note:** Does NOT evaluate beziers, uses the control points only. -*/ -function establishDimensions(){ - let minX = Infinity; - let minY = Infinity; - let minAlt = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - let maxAlt = -Infinity; - for(const obj of mapData){ - if(obj.type == 'bezier') - for(const point of obj.points){ - minX = Math.min(minX, point.position.x, point.h1.x, point.h2.x); - minY = Math.min(minY, point.position.z, point.h1.z, point.h2.z); - minAlt = Math.min(minAlt, point.position.y, point.h1.y, point.h2.y); - maxX = Math.max(maxX, point.position.x, point.h1.x, point.h2.x); - maxY = Math.max(maxY, point.position.z, point.h1.z, point.h2.z); - maxAlt = Math.max(maxAlt, point.position.y, point.h1.y, point.h2.y); - } - } - let width = maxX-minX; - let height = maxY-minY; - MapMatrix = { - x1: minX, - x2: maxX, - y1: minY, - y2: maxY, - minAlt: minAlt, - maxAlt: maxAlt, - width: width, - height: height, - fixY: yIn => { - return minY+maxY-yIn; - } - } - map.setAttribute('viewBox', `${minX-padding} ${minY-padding} ${width+2*padding} ${height+2*padding}`); - map_markers.setAttribute('viewBox', map.getAttribute('viewBox')); - let baseScale = Math.min(map_container.clientWidth/map.clientWidth, map_container.clientHeight/map.clientHeight); - let isLandscape = map_container.clientWidth > map_container.clientHeight; - mapNav = { - x: isLandscape ? -map.clientWidth/2*baseScale + map_container.clientWidth/2 : 0, - y: isLandscape ? 0 : -map.clientHeight/2*baseScale + map_container.clientHeight/2, - scale: baseScale - }; - updateMapview(); - console.log(`(${minX}, ${minY}) to (${maxX}, ${maxY}). Altitude from ${minAlt} to ${maxAlt}`); - - // Flip the z coordinate so the map doesn't display upside-down - for(const obj of mapData){ - if(obj.points) - for(const point of obj.points){ - point.position.z = minY+maxY-point.position.z; - point.h1.z = minY+maxY-point.h1.z; - point.h2.z = minY+maxY-point.h2.z; - } - if(obj.position) - obj.position.z = minY+maxY-obj.position.z; - } -} - -/** Populate the SVG data */ -function fillSvg(){ - for(const obj of mapData){ - switch(obj.type){ - case 'bezier': - drawTracks(obj); - createTrackSignage(obj); - break; - case 'junction': - addJunction(obj); - break; - } - } - createYardSignage(); - sortTracks(); - setTrackColorMode('Grade'); - drawSignage(); -} - -/** Insert individual rail paths into the SVG */ -function drawTracks(bezierData){ - if(trackNameCounting[bezierData.name] == null){ - trackNameCounting[bezierData.name] = 0; - }else{ - trackNameCounting[bezierData.name]++; - } - let yardData = /\[(#)\]|\[(Y)\][_-]\[(.*?)\][_-]\[((.*?)[_-](.*?)[_-](.*?)|.*)\]/.exec(bezierData.name); - if(yardData){ - if(yardData[1]){ // track is [#], thus in yard limits, but not a specific designated yard. - bezierData.isYard = true; - }else if(yardData[2]){ // track is [Y], and is a specific yard. - bezierData.isYard = true; - bezierData.yardStation = yardData[3]; // Which station the track belongs to - bezierData.yardDesignation = yardData[4]; // Full yard designation, used on signs - bezierData.yardName = yardData[5]; // A yard, B yard, C yard, etc. - bezierData.yardSidingNumber = yardData[6]; // Which siding within the yard - bezierData.yardUsage = yardData[7]; // Outbound? Parking? Inbound? Loading? That's this. - } - }else if(/Turntable Track/.test(bezierData.name)){ - bezierData.isTurntable = true; - }else if(/\[track (diverging|through)\]/.test(bezierData.name)){ - bezierData.isPoint = true; - } - bezierData.randomColor = Color.random(); - for(let i=0; i+1 0 ? ' #'+trackNameCounting[bezierData.name] : ''} Sec${i}`, - `Max Grade: ${Math.round(bezierData.points[i].grade*1000)/10}%`, - `Min Radius: ${Math.round(1/bezierData.points[i].curvature)} meters`, - `Top Speed: ${Utils.radiusToSpeed(1/bezierData.points[i].curvature)} km/h`, - `Length: ${Math.round(bezierData.points[i].bezLength*10)/10} meters`, - `Altitude (ASL): ${Math.round((bezStart.y+bezEnd.y)*0.5)-110} meters`, - ].join('\n'); - bezierData.points[i].element.appendChild(title); - } -} - -/** Create signage associated with the track section */ -function createTrackSignage(bezierData){ - let trackZoneData = {}; - let finalSegment = false; - for(let i=0; i { - trackZoneData.gradeClass = bezierData.points[i].gradeClass; - trackZoneData.gradeDirection = Math.sign(bezierData.points[i].grade); - trackZoneData.gradeCount = 0; - trackZoneData.gradeSectionLength = 0; - }; - let resetTzdSpeed = () => { - trackZoneData.prevSpeed = trackZoneData.speed; - trackZoneData.speed = bezierData.points[i].postedSpeed; - trackZoneData.speedCount = 0; - trackZoneData.speedSectionLength = 0; - } - if(i <= 0){ - resetTzdGrade(); - resetTzdSpeed(); - } - //Grade Signs - if(trackZoneData.gradeClass != bezierData.points[curSeg].gradeClass || trackZoneData.gradeDirection != Math.sign(bezierData.points[curSeg].grade) || trackZoneData.gradeSectionLength > maxSectionLength || finalSegment){ - if(trackZoneData.gradeClass != 'flat'){ - let offset = Math.floor((trackZoneData.gradeCount+1)/2); - let center = trackZoneData.gradeCount % 2 ? 0.55 : 0.05; - let b1 = bezierData.points[i-offset].position; - let b2 = bezierData.points[i-offset].h2; - let b3 = bezierData.points[i+1-offset].h1; - let b4 = bezierData.points[i+1-offset].position; - let cullLevel = Math.round(scaleCullThresholds.length-1-trackZoneData.gradeSectionLength/scaleCullBaseSectionLength); - if(bezierData.isYard || bezierData.isTurntable){ - cullLevel = Math.max(cullLevel, 4); - } - markers.push({ - type: 'grade', - value: bezierData.points[i-offset].grade, - gradeClass: trackZoneData.gradeClass, - position: Bezier.evaluatePoint(b1, b2, b3, b4, center), - sectionLength: trackZoneData.gradeSectionLength, - sectionCount: trackZoneData.gradeCount, - cullLevel: cullLevel, - tangent: bezierData.points[i-offset].grade > 0 - ? Bezier.evaluateVelocity(b1, b2, b3, b4, center) - : Bezier.evaluateVelocity(b4, b3, b2, b1, center) - }); - } - resetTzdGrade(); - } - trackZoneData.gradeCount++; - trackZoneData.gradeSectionLength += bezierData.points[curSeg].bezLength; - - //Speed Signs - if(trackZoneData.speed != bezierData.points[curSeg].postedSpeed || trackZoneData.speedSectionLength > maxSectionLength || finalSegment){ - let offset = Math.floor((trackZoneData.speedCount+1)/2); - let center = trackZoneData.speedCount % 2 ? 0.5 : 0.0; - let b1 = bezierData.points[i-offset].position; - let b2 = bezierData.points[i-offset].h2; - let b3 = bezierData.points[i+1-offset].h1; - let b4 = bezierData.points[i+1-offset].position; - let cullLevel = Math.round(scaleCullThresholds.length-1-trackZoneData.gradeSectionLength/scaleCullBaseSectionLengthSpeedboard); - let speedDelta = Math.max(0, - (bezierData.points[curSeg].postedSpeed != null && bezierData.points[curSeg].postedSpeed < 100) ? bezierData.points[curSeg].postedSpeed-trackZoneData.speed : 0, - (trackZoneData.prevSpeed != null && trackZoneData.prevSpeed < 100) ? trackZoneData.prevSpeed-trackZoneData.speed : 0 - ); - if(bezierData.points.length == 2 && trackZoneData.speed > 30){ - cullLevel = 6; - }else if(speedDelta >= 10 && trackZoneData.speed <= 30){ - cullLevel = 0; - }else if(speedDelta >= 20){ - cullLevel = 1; - } - if(bezierData.isYard || bezierData.isTurntable){ - cullLevel = Math.min(cullLevel+1, scaleCullThresholds.length-1); - } - markers.push({ - type: 'speed', - value: trackZoneData.speed, - position: Bezier.evaluatePoint(b1, b2, b3, b4, center), - sectionLength: trackZoneData.speedSectionLength, - sectionCount: trackZoneData.speedCount, - cullLevel: cullLevel, - tangent: Bezier.evaluateVelocity(b1, b2, b3, b4, center) - }); - resetTzdSpeed(); - } - trackZoneData.speedCount++; - trackZoneData.speedSectionLength += bezierData.points[curSeg].bezLength; - } - - // Yard Signage - if(bezierData.yardDesignation && bezierData.yardDesignation != '--'){ - if(!yardMarkers[bezierData.yardStation]) yardMarkers[bezierData.yardStation] = {}; - let centerIndex = Math.floor((bezierData.points.length-1)/2); - let yardSignPos = bezierData.points[centerIndex].position; - if(bezierData.points.length % 2 == 0){ - yardSignPos = Bezier.evaluatePoint( - bezierData.points[centerIndex].position, - bezierData.points[centerIndex].h2, - bezierData.points[centerIndex+1].h1, - bezierData.points[centerIndex].position, - 0.5); - } - if(bezierData.yardName){ - if(!yardMarkers[bezierData.yardStation][bezierData.yardName]) yardMarkers[bezierData.yardStation][bezierData.yardName] = {accCenter:{x:0,y:0,z:0}, accCount:0}; - yardMarkers[bezierData.yardStation][bezierData.yardName].accCenter.x += yardSignPos.x; - yardMarkers[bezierData.yardStation][bezierData.yardName].accCenter.y += yardSignPos.y; - yardMarkers[bezierData.yardStation][bezierData.yardName].accCenter.z += yardSignPos.z; - yardMarkers[bezierData.yardStation][bezierData.yardName].accCount++; - } - markers.push({ - type: 'yardSiding', - name: bezierData.yardDesignation, - station: bezierData.yardStation, - yard: bezierData.yardName, - siding: bezierData.yardSidingNumber, - usage: bezierData.yardUsage, - position: yardSignPos - }); - } -} - -function createYardSignage(){ - for(let station in yardMarkers){ - for(let yard in yardMarkers[station]){ - markers.push({ - type: 'yard', - value: yard, - position: { - x: yardMarkers[station][yard].accCenter.x/yardMarkers[station][yard].accCount, - y: yardMarkers[station][yard].accCenter.y/yardMarkers[station][yard].accCount, - z: yardMarkers[station][yard].accCenter.z/yardMarkers[station][yard].accCount, - } - }); - } - } -} - -function addJunction(junctionData){ - if(junctionData.name.startsWith('S-') || junctionData.excludeFromJunctionMap) - junctionData.cullLevel = 4; - else if(junctionData.name.startsWith('W-')) - junctionData.cullLevel = 1; - markers.push(junctionData); -} - -/** Sort the tracks and put them into the svg */ -function sortTracks(){ - tracks.sort((a,b) => { - return a[0]-b[0]; + mapContainer.addEventListener('mousemove', event => { + tooltipX = event.clientX; + tooltipY = event.clientY; + removeTooltip = false; + tooltipDirty = true; + }); + mapContainer.addEventListener('mouseleave', event => { + removeTooltip = true; + tooltipDirty = true; }); - for(const track of tracks){ - map_rails.appendChild(track[1]); - } -} -export function setTrackColorMode(mode){ - let modeFunction; - const keyElement = document.getElementById('legendKeyColors'); - let gradientString = ''; - keyElement.innerHTML = ''; - switch(mode){ - case 'Track Type': - modeFunction = (section, curve) => { - let finalColor = '#aaa'; - if(curve.isYard && curve.yardDesignation) finalColor = '#4bf'; - else if(curve.isYard) finalColor = '#4fb'; - else if(curve.isPoint) finalColor = '#fb4'; - else if(curve.isTurntable) finalColor = '#b4f'; - - section.element.setAttribute('stroke', finalColor); - }; - keyElement.innerHTML = ` - Designated Yard - Other Yard - Point - Turntable - Other Tracks`; - break; - case 'Grade': - modeFunction = section => { - section.element.setAttribute('stroke', Config.gradeColors['grade_'+section.gradeClass]); - }; - for(let grade in Config.gradeColors){ - keyElement.innerHTML += `${(Utils.classToGrade(grade.replace('grade_',''))*100).toFixed(1)}%`; + const tooltipUpdate = ts => { + if(ts && tooltipDirty){ + let tooltipContents = MapData.testTooltip(tooltipX, tooltipY); + if(tooltipContents != '' && !removeTooltip && !isNavigating){ + tooltip.innerHTML = tooltipContents.replaceAll('\n','
'); + let tooltipWidth = tooltip.clientWidth; + let tooltipHeight = tooltip.clientHeight; + tooltipX += 5; + tooltipY += 5; + if(tooltipWidth+tooltipX + 10 > mapContainer.clientWidth) + tooltipX -= tooltipWidth + 15; + if(tooltipHeight+tooltipY + 10 > mapContainer.clientHeight) + tooltipY -= tooltipHeight + 10; + tooltip.style.transform = `translate(${tooltipX}px, ${tooltipY}px)`; + tooltip.style.display = ''; + mapCanvas.style.cursor = 'crosshair'; + }else{ + tooltip.style.display = 'none'; + mapCanvas.style.cursor = ''; } - break; - case 'Altitude': - modeFunction = section => { - section.element.setAttribute('stroke', Color.blendGradient(Config.altitudeGradient, (section.position.y-MapMatrix.minAlt)/(MapMatrix.maxAlt-MapMatrix.minAlt)).hex); - } - gradientString = 'linear-gradient(to right'; - for(let col of Config.altitudeGradient) gradientString += ', '+col.hex; - gradientString += ')'; - - keyElement.innerHTML = ` -
-
${Math.floor(MapMatrix.minAlt)}m
-
-
${Math.ceil(MapMatrix.maxAlt)}m
-
- `; - break; - case 'Speed': - modeFunction = section => { - section.element.setAttribute('stroke', Color.blendGradient(Config.speedGradient, (section.postedSpeed+10)/110).hex); - }; - - gradientString = 'linear-gradient(to right'; - for(let col of Config.speedGradient) gradientString += ', '+col.hex; - gradientString += ')'; - keyElement.innerHTML = ` -
-
10 kmh
-
-
120 kmh
-
- `; - break; - case 'Track Random': - modeFunction = (section, curve) => { - section.element.setAttribute('stroke', curve.randomColor.hex); - }; - keyElement.innerHTML = 'Random per continuous track section'; - break; - case 'Curve Random': - modeFunction = section => { - section.element.setAttribute('stroke', section.randomColor.hex); - }; - keyElement.innerHTML = 'Random per individual track bezier curve'; - break; - default: - modeFunction = section => { - section.element.setAttribute('stroke', '#fff'); - }; - break; - } - if(modeFunction != null){ - for(let curve of mapData){ - if(curve.type == 'bezier') - for(let section of curve.points){ - if(!section.element) continue; - modeFunction(section, curve); - } + tooltipDirty = false; } + requestAnimationFrame(tooltipUpdate); } + tooltipUpdate(); } -/** Create the signage */ -function drawSignage(){ - const signage_container = document.createElementNS(svgns, 'g'); - const gradeSigns = document.createElementNS(svgns, 'g'); - const speedSigns = document.createElementNS(svgns, 'g'); - const yardSigns = document.createElementNS(svgns, 'g'); - const junctionSigns = document.createElementNS(svgns, 'g'); +async function loadTrackData(file){ + let mapData = await (await fetch(new Request(file))).json(); - signage_container.setAttribute('id', 'signage_container'); - gradeSigns.setAttribute('id', 'signage_grade'); - speedSigns.setAttribute('id', 'signage_speed'); - yardSigns.setAttribute('id', 'signage_yard'); - junctionSigns.setAttribute('id', 'signage_junction'); - - map_markers.appendChild(signage_container); - signage_container.appendChild(gradeSigns); - signage_container.appendChild(speedSigns); - signage_container.appendChild(yardSigns); - signage_container.appendChild(junctionSigns); - - junctionSigns.style.display = 'none'; - - for(const markerData of markers){ - let markerElement = document.createElementNS(svgns, 'g'); - let markerImg = document.createElementNS(svgns, 'use'); - let markerTooltip = document.createElementNS(svgns, 'title'); - markerElement.appendChild(markerImg); - markerElement.appendChild(markerTooltip); - markerElement.setAttribute('transform', `translate(${markerData.position.x} ${markerData.position.z})`); - markerImg.classList.add('fixedScale', 'sign'); - switch(markerData.type){ - case 'grade': - markerImg.classList.add('gradeArrow'); - markerImg.setAttribute('href', '#gradeArrow'); - markerImg.setAttribute('fill', Config.gradeColors['grade_'+markerData.gradeClass]); - const tanLen = Math.sqrt(markerData.tangent.x*markerData.tangent.x + markerData.tangent.z*markerData.tangent.z) * Math.sign(markerData.tangent.y); - const rot = Math.atan2(markerData.tangent.x/tanLen, -markerData.tangent.z/tanLen) * (180/Math.PI); - markerElement.setAttribute('transform', `translate(${markerData.position.x} ${markerData.position.z}) rotate(${rot})`); - markerTooltip.innerHTML = `${Math.round(markerData.value*1000)/10}% Grade\nSection Length: ${markerData.sectionLength.toFixed(1)} meters\nCovers ${markerData.sectionCount} sections`; - gradeSigns.appendChild(markerElement); - break; - case 'speed': - markerImg.setAttribute('href', `#speedSign_${Math.round(markerData.value/10)}`); - markerTooltip.innerHTML = `${Math.round(markerData.value)} km/h\nSection Length: ${markerData.sectionLength.toFixed(1)} meters\nCovers ${markerData.sectionCount} sections`; - markerElement.setAttribute('transform', `translate(${markerData.position.x} ${markerData.position.z})`); - speedSigns.appendChild(markerElement); - break; - case 'yardSiding': - markerImg.remove(); - let yardSidingSign = document.createElementNS(svgns,'g'); - yardSidingSign.classList.add('fixedScale', 'sign', 'yardSignage', 'minZoomCull_5'); - markerElement.appendChild(yardSidingSign); - - let yardSidingBoard = document.createElementNS(svgns, 'rect'); - let sidingBoardHeight = 230; - let sidingBoardWidth = (markerData.name.length+1) * 121; - yardSidingBoard.setAttribute('width', sidingBoardWidth); - yardSidingBoard.setAttribute('height', sidingBoardHeight); - yardSidingBoard.setAttribute('fill', '#fff'); - yardSidingBoard.setAttribute('stroke', '#000'); - yardSidingBoard.setAttribute('stroke-width', 30); - yardSidingBoard.setAttribute('rx', 45); - yardSidingBoard.setAttribute('transform', `translate(-${sidingBoardWidth/2}, -${sidingBoardHeight/2})`); - yardSidingBoard.appendChild(markerTooltip); - yardSidingSign.appendChild(yardSidingBoard); - - let yardSidingText = document.createElementNS(svgns, 'text'); - yardSidingText.innerHTML = markerData.name; - yardSidingText.setAttribute('transform', 'translate(0, 70)'); - yardSidingText.setAttribute('pointer-events', 'none'); - yardSidingSign.appendChild(yardSidingText); - if(markerData.yard && markerData.siding && markerData.usage){ - if(!Config.sidingUsageMeanings[markerData.usage]) console.log(markerData.station+' '+markerData.name); - markerTooltip.innerHTML = `${markerData.yard}-${markerData.siding}, ${Config.sidingUsageMeanings[markerData.usage]}`; - }else{ - markerTooltip.innerHTML = markerData.name; - } - - yardSigns.appendChild(markerElement); - break; - case 'yard': - markerImg.remove(); - let yardSign = document.createElementNS(svgns,'g'); - yardSign.classList.add('fixedScale', 'sign', 'yardSignage', 'maxZoomCull_5', 'minZoomCull_2'); - markerElement.appendChild(yardSign); - - let signText = markerData.value+' Yard'; - - let yardBoard = document.createElementNS(svgns, 'rect'); - let boardHeight = 230; - let boardWidth = (signText.length+1) * 121; - yardBoard.setAttribute('width', boardWidth); - yardBoard.setAttribute('height', boardHeight); - yardBoard.setAttribute('fill', '#fff'); - yardBoard.setAttribute('stroke', '#000'); - yardBoard.setAttribute('stroke-width', 30); - yardBoard.setAttribute('rx', 45); - yardBoard.setAttribute('transform', `translate(-${boardWidth/2}, -${boardHeight/2})`); - yardBoard.appendChild(markerTooltip); - yardSign.appendChild(yardBoard); - - let yardText = document.createElementNS(svgns, 'text'); - yardText.innerHTML = signText; - yardText.setAttribute('transform', 'translate(0, 70)'); - yardText.setAttribute('pointer-events', 'none'); - yardSign.appendChild(yardText); - markerTooltip.innerHTML = signText; - - yardSigns.appendChild(markerElement); + for(const trackItem of mapData){ + switch(trackItem.type){ + case 'bezier': + MapData.addTrack(trackItem); break; case 'junction': - markerImg.setAttribute('href', `#junctionSign`); - markerImg.setAttribute('fill', '#de2121'); - markerTooltip.innerHTML = `"${markerData.name}" - #${markerData.index}\n${markerData.excludeFromJunctionMap ? 'Inv' : 'V'}isible On Map\nLinked Junctions:\n- ${markerData.linkedJunctions.join('\n- ')}`; - markerElement.setAttribute('transform', `translate(${markerData.position.x} ${markerData.position.z})`); - junctionSigns.appendChild(markerElement); + MapData.addJunction(trackItem); break; default: - markerElement.remove(); - continue; + console.log(`Unknown type "${trackItem.type}"`); + break; } + } - if(markerData.debug != null) markerTooltip.innerHTML += '\n'+markerData.debug; + MapData.sortTracks(); + MapData.generateTrackSignage(); +} - if(markerData.cullLevel){ - markerElement.classList.add(`minZoomCull_${Math.min(scaleCullThresholds.length-1, markerData.cullLevel)}`); - } +async function loadPoiData(file){ + let poiData = await (await fetch(new Request(file))).json(); + for(let poi of poiData){ + MapData.addPoi(poi); } } -function addPoi(poi, level = 0){ - let poiElement = document.createElementNS(svgns, 'g'); - let poiDisplay = document.createElementNS(svgns, 'g'); - let poiIcon = document.createElementNS(svgns, poi.type == 'station' ? 'text' : 'use'); - let poiTooltip = document.createElementNS(svgns, 'title'); - poiElement.appendChild(poiDisplay); - poiDisplay.appendChild(poiIcon); - poiElement.appendChild(poiTooltip); - poiElement.setAttribute('transform', `translate(${poi.position.x} ${MapMatrix.fixY(poi.position.z)})`); - poiElement.classList.add('poi'); - poiDisplay.classList.add('fixedScale'); - if(level > 0) poiElement.classList.add('minZoomCull_2'); - switch(poi.type){ - case 'station': - poiDisplay.classList.add('maxZoomCull_4'); - poiDisplay.classList.remove('fixedScale'); - poiElement.setAttribute('fill', poi.color ?? '#fff'); - poiTooltip.innerHTML = poi.name; - poiIcon.innerHTML = poi.shorthand; - poiIcon.setAttribute('style', `font-size:400px; pointer-events:none; stroke:${poi.stroke ?? '#000'}; stroke-width:40px; paint-order:stroke; text-anchor:middle;`); - let stationName = document.createElementNS(svgns, 'text'); - stationName.innerHTML = poi.nickname ?? poi.name; - stationName.setAttribute('style', `transform: translate(0px, 120px); font-size:100px; pointer-events:visible; stroke:${poi.stroke ?? '#000'}; stroke-width:20px; paint-order:stroke; text-anchor:middle;`) - stationName.classList.add('minZoomCull_1'); - poiDisplay.appendChild(stationName); - poiLayers.stations.prepend(poiElement); - break; - case 'office': - poiIcon.setAttribute('href', '#mrk_office'); - if(poi.military){ - poiDisplay.setAttribute('fill', '#978e47'); - poiTooltip.innerHTML = 'Military Office'; - }else if(poi.museum){ - poiDisplay.setAttribute('fill', '#b44'); - poiTooltip.innerHTML = 'Museum Office'; - }else{ - poiDisplay.setAttribute('fill', '#c4693e'); - poiTooltip.innerHTML = 'Station Office'; - } - poiLayers.service.prepend(poiElement); - break; - case 'coal': - poiIcon.setAttribute('href', '#mrk_coal'); - poiDisplay.setAttribute('fill', '#202020'); - poiTooltip.innerHTML = 'Coal Tower'; - poiLayers.service.prepend(poiElement); - break; - case 'water': - poiIcon.setAttribute('href', '#mrk_water'); - poiDisplay.setAttribute('fill', '#3fa5ff'); - poiTooltip.innerHTML = 'Water Tower'; - poiLayers.service.prepend(poiElement); - break; - case 'garage': - poiIcon.setAttribute('href', '#mrk_garage'); - poiDisplay.setAttribute('fill', '#8b5dd7'); - poiTooltip.innerHTML = poi.name ?? 'Unknown Garage'; - poiLayers.service.prepend(poiElement); - poiElement.classList.add('minZoomCull_1'); - break; - case 'landmark': - poiIcon.setAttribute('href', '#mrk_landmark'); - poiDisplay.setAttribute('fill', '#af5757'); - poiTooltip.innerHTML = poi.name ?? 'Unknown Landmark'; - poiLayers.landmarks.prepend(poiElement); - poiElement.classList.add('minZoomCull_1'); - break; - case 'shop': - poiIcon.setAttribute('href', '#mrk_shop'); - poiDisplay.setAttribute('fill', '#4f54e9'); - poiTooltip.innerHTML = 'Shop'; - poiLayers.service.prepend(poiElement); - poiElement.classList.add('minZoomCull_1'); - break; - case 'service': - let serviceTypes = []; - if(poi.repair) serviceTypes.push('Repair'); - if(poi.diesel) serviceTypes.push('Diesel'); - if(poi.charger) serviceTypes.push('Charger'); - if(serviceTypes.length == 0){ - poiIcon.setAttribute('href', '#mrk_service'); - poiDisplay.setAttribute('fill', '#239a96'); - poiTooltip.innerHTML = 'Unknown Service'; - }else{ - poiIcon.setAttribute('href', '#mrk_service_'+serviceTypes[0].toLowerCase()); - poiDisplay.setAttribute('fill', '#239a96'); - let bumpOffset = -200*(serviceTypes.length-1); - poiIcon.setAttribute('transform', `translate(${bumpOffset} 0)`); - poiTooltip.innerHTML = serviceTypes[0]; - for(let i=1; i 1){ - let bgrect = document.createElementNS(svgns, 'rect'); - bgrect.setAttribute('width', 400*(serviceTypes.length-1)); - bgrect.setAttribute('height', 400); - bgrect.setAttribute('transform', `translate(${-200*(serviceTypes.length-1)} -200)`); - poiDisplay.prepend(bgrect); - } - } - poiLayers.service.prepend(poiElement); - break; + if(MapData.layers.terrain){ + mapctx.drawImage( + mapTerrain, + MapData.view.convertX(0), + MapData.view.convertY(16384), + 16384 * MapData.view.scale, + 16384 * MapData.view.scale + ); } - if(poi.children){ - for(let child of poi.children){ - addPoi(child, level+1); + // Game gauge is 1.5 meter + const trackWidth = Math.min(Math.max(6, MapData.view.scale * 1.5), MapData.view.scale * 100); + mapctx.lineWidth = trackWidth; + + for(const railTrack of MapData.railTracks){ + for(const curve of railTrack.curves){ + mapctx.beginPath(); + mapctx.moveTo(MapData.view.convertX(curve.start.x), MapData.view.convertY(curve.start.z)); + mapctx.bezierCurveTo( + MapData.view.convertX(curve.h1.x), + MapData.view.convertY(curve.h1.z), + MapData.view.convertX(curve.h2.x), + MapData.view.convertY(curve.h2.z), + MapData.view.convertX(curve.end.x), + MapData.view.convertY(curve.end.z) + ); + mapctx.strokeStyle = curve.color; + mapctx.stroke(); } } + let curSprite; + let spriteSize; + let textMeasure; + let tempMeasure = {}; + for(const marker of MapData.markers){ + curSprite = null; + marker.visible = false; + if((marker.minZoom && MapData.view.scale < marker.minZoom) || (marker.maxZoom && MapData.view.scale > marker.maxZoom)) continue; + let markerX = MapData.view.convertX(marker.position.x); + let markerY = MapData.view.convertY(marker.position.z); + if(markerX > mapCanvas.width + Config.viewCullMargin || markerX < -Config.viewCullMargin || markerY > mapCanvas.height + Config.viewCullMargin || markerY < -Config.viewCullMargin) continue; // View Culling + switch(marker.type){ + case 'junction': + if(!(MapData.layers.junctions && MapData.layers.signage)) break; + marker.visible = true; + curSprite = Config.spriteBounds.junction; + spriteSize = 30; + break; + case 'grade': + if(!(MapData.layers.gradeArrows && MapData.layers.signage)) break; + marker.visible = true; + curSprite = Config.spriteBounds[`gradeArrow${MapData.layers.gradeDirection}_${marker.gradeClass}`]; + spriteSize = 30; + mapctx.translate(markerX, markerY); + mapctx.rotate(marker.rotation); + mapctx.translate(-0.5*spriteSize, -0.5*spriteSize); + mapctx.drawImage( + Utils.gradeArrows, + curSprite.x, + curSprite.y, + curSprite.width, + curSprite.height, + 0, + 0, + spriteSize, + spriteSize + ); + mapctx.resetTransform(); + curSprite = null; + break; + case 'speed': + if(!(MapData.layers.speedSigns && MapData.layers.signage)) break; + marker.visible = true; + curSprite = Config.spriteBounds['speed_'+Math.floor(marker.value/10)]; + spriteSize = 24; + break; + case 'service': + if(!(MapData.layers.services && MapData.layers.poi)) break; + marker.visible = true; + spriteSize = 30; + if(marker.serviceTypes.length == 0) marker.serviceTypes.push(''); + if(marker.serviceTypes.length > 1){ + mapctx.fillStyle = '#239a96'; + tempMeasure.width = spriteSize * (marker.serviceTypes.length-1); + mapctx.fillRect(markerX - tempMeasure.width*0.5, markerY - spriteSize*0.5, tempMeasure.width, spriteSize); + } + for(let i=0; i { + matrix.minX = Infinity; + matrix.minY = Infinity; + matrix.minAlt = Infinity; + matrix.maxX = -Infinity; + matrix.maxY = -Infinity; + matrix.maxAlt = -Infinity; + + for(const railTrack of railTracks){ + for(const curve of railTrack.curves){ + matrix.minX = Math.min(matrix.minX, curve.start.x, curve.h1.x, curve.h2.x, curve.end.x); + matrix.minY = Math.min(matrix.minY, curve.start.z, curve.h1.z, curve.h2.z, curve.end.z); + matrix.minAlt = Math.min(matrix.minAlt, curve.start.y, curve.h1.y, curve.h2.y, curve.end.y); + matrix.maxX = Math.max(matrix.maxX, curve.start.x, curve.h1.x, curve.h2.x, curve.end.x); + matrix.maxY = Math.max(matrix.maxY, curve.start.z, curve.h1.z, curve.h2.z, curve.end.z); + matrix.maxAlt = Math.max(matrix.maxAlt, curve.start.y, curve.h1.y, curve.h2.y, curve.end.y); + } + } + matrix.width = matrix.maxX-matrix.minX; + matrix.height = matrix.maxY-matrix.minY; + + console.log(`(${matrix.minX}, ${matrix.minY}) to (${matrix.maxX}, ${matrix.maxY}). Altitude from ${matrix.minAlt} to ${matrix.maxAlt}`); + } +}; + +export let view = { + initialize: () => { + const mapContainer = document.getElementById('mapContainer'); + let isLandscape = mapContainer.clientWidth > mapContainer.clientHeight; + view.scale = Math.min(mapContainer.clientWidth/(matrix.width + Config.defaultMapPadding*2), mapContainer.clientHeight/(matrix.height + Config.defaultMapPadding*2)); + if(isLandscape){ + view.x = mapContainer.clientWidth*0.6 - (matrix.width * 0.5 + matrix.minX)*view.scale; + view.y = (matrix.minY - Config.defaultMapPadding) * view.scale; + }else{ + view.x = -(matrix.minX - Config.defaultMapPadding) * view.scale; + view.y = -(mapContainer.clientHeight*0.5 - (matrix.height * 0.5 + matrix.minY)*view.scale); + } + view.dirty = false; + + view.convertX = x => { + return x * view.scale + view.x; + } + view.convertY = y => { + return (matrix.minY+matrix.maxY-y) * view.scale - view.y; + } + + view.unconvertX = x => { + return (x - view.x) / view.scale; + } + view.unconvertY = y => { + return -((y + view.y) / view.scale - (matrix.minY + matrix.maxY)); + } + } +} + +export let layers = {}; + +export function reset(){ + railTracks = []; + markers = []; +} + +export function sortTracks(){ + railTracks.sort((a,b) => { + if(a.curves && b.curves) + return a.curves[0].start.y-b.curves[b.curves.length-1].end.y; + return 0; + }); +} + +export function sortMarkers(){ + markers.sort((a,b) => { + if(a.type == b.type){ + switch(a.type){ + case 'speed': + return b.value-a.value; + default: + return 0; + } + } + if(a.type == 'station') return 1; + if(b.type == 'station') return -1; + if(a.type == 'speed') return 1; + if(b.type == 'speed') return -1; + if(a.type == 'junction') return 1; + if(b.type == 'junction') return -1; + if(a.type == 'service') return 1; + if(b.type == 'service') return -1; + return 0; + }); +} + +export function filterToTrack(trackName, trackNum = 0){ + if(typeof(trackName) == 'string'){ + for(let railTrack of railTracks){ + if(railTrack.name == trackName && railTrack.number == trackNum){ + railTracks = [railTrack]; + break; + } + } + }else if(typeof(trackName) == 'object'){ + let newRailTracks = []; + for(let railTrack of railTracks){ + for(let filter of trackName){ + if(railTrack.name == filter.name && railTrack.number == (filter.number ?? 0)){ + newRailTracks.push(railTrack); + } + } + } + railTracks = newRailTracks; + } +} + +export function addTrack(trackData){ + let newTrack = { + name: trackData.name, + curves: [], + bounds: {min:{x:Infinity,y:Infinity,z:Infinity},max:{x:-Infinity,y:-Infinity,z:-Infinity}}, + }; + if(uniqueTrackNames[trackData.name] == null){ + uniqueTrackNames[trackData.name] = 0; + }else{ + uniqueTrackNames[trackData.name]++; + } + newTrack.number = uniqueTrackNames[trackData.name]; + + let yardData = /\[(#)\]|\[(Y)\][_-]\[(.*?)\][_-]\[((.*?)[_-](.*?)[_-](.*?)|.*)\]/.exec(trackData.name); + if(yardData){ + if(yardData[1]){ // track is [#], thus in yard limits, but not a specific designated yard. + newTrack.isYard = true; + }else if(yardData[2]){ // track is [Y], and is a specific yard. + newTrack.isYard = true; + newTrack.yardStation = yardData[3]; // Which station the track belongs to + newTrack.yardDesignation = yardData[4]; // Full yard designation, used on signs + newTrack.yardName = yardData[5]; // A yard, B yard, C yard, etc. + newTrack.yardSidingNumber = yardData[6]; // Which siding within the yard + newTrack.yardUsage = yardData[7]; // Outbound? Parking? Inbound? Loading? That's this. + } + }else if(/Turntable Track/.test(newTrack.name)){ + newTrack.isTurntable = true; + }else if(/\[track (diverging|through)\]/.test(newTrack.name)){ + newTrack.isJunction = true; + } + newTrack.randomColor = Color.random(); + + for(let i=0; i+1Rail"${trackData.name}"${uniqueTrackNames[trackData.name] > 0 ? ' #'+uniqueTrackNames[trackData.name] : ''} Sec${i}`, + `Max Grade: ${Math.round(newTrack.curves[i].grade*1000)/10}%`, + `Min Radius: ${Math.round(1/newTrack.curves[i].curvature)} meters`, + `Top Speed: ${Utils.radiusToSpeed(1/newTrack.curves[i].curvature)} km/h`, + `Length: ${Math.round(newTrack.curves[i].length*10)/10} meters`, + `Altitude (ASL): ${Math.round(newTrack.curves[i].averageHeight)-110} meters`, + ].join('\n'); + + newTrack.bounds.min.x = Math.min(newTrack.bounds.min.x, newTrack.curves[i].bounds.min.x); + newTrack.bounds.max.x = Math.max(newTrack.bounds.max.x, newTrack.curves[i].bounds.max.x); + newTrack.bounds.min.y = Math.min(newTrack.bounds.min.y, newTrack.curves[i].bounds.min.y); + newTrack.bounds.max.y = Math.max(newTrack.bounds.max.y, newTrack.curves[i].bounds.max.y); + newTrack.bounds.min.z = Math.min(newTrack.bounds.min.z, newTrack.curves[i].bounds.min.z); + newTrack.bounds.max.z = Math.max(newTrack.bounds.max.z, newTrack.curves[i].bounds.max.z); + } + + railTracks.push(newTrack); +} + +export function addJunction(junctionData){ + if(junctionData.name.startsWith('S-') || junctionData.excludeFromJunctionMap) + junctionData.minZoom = 1; + else if(junctionData.name.startsWith('W-')) + junctionData.minZoom = 0.05; + junctionData.tooltip = `

Junction

"${junctionData.name}" - #${junctionData.index}\n${junctionData.excludeFromJunctionMap ? 'Inv' : 'V'}isible On Map\nLinked Junctions:\n- ${junctionData.linkedJunctions.join('\n- ')}`; + junctionData.tooltipHitzone = Config.tooltipHitzone.junction; + markers.push(junctionData); +} + +export function addPoi(poiData, level = 0){ + let newPoi = { + type: poiData.type, + name: poiData.name, + color: poiData.color, + position: poiData.position, + }; + if(level > 0) newPoi.minZoom = 0.3; + + switch(poiData.type){ + case 'service': + newPoi.serviceTypes = []; + if(poiData.repair) newPoi.serviceTypes.push('Repair'); + if(poiData.diesel) newPoi.serviceTypes.push('Diesel'); + if(poiData.charger) newPoi.serviceTypes.push('Charger'); + if(newPoi.serviceTypes.length == 0){ + newPoi.tooltip = '

Service


?'; + }else{ + newPoi.tooltip = '

Service


- '+newPoi.serviceTypes.join('\n- '); + } + break; + case 'station': + newPoi.maxZoom = 1; + newPoi.nickname = poiData.nickname; + newPoi.shorthand = poiData.shorthand; + newPoi.color = poiData.color; + newPoi.tooltip = `

${poiData.name}

`; + break; + case 'shop': + newPoi.tooltip = `

Shop

${poiData.stock ? '
'+poiData.stock.sort().join('\n') : ''}`; + break; + case 'water': + newPoi.tooltip = '

Water Tower

'; + break; + case 'coal': + newPoi.tooltip = '

Coal Tower

'; + break; + case 'office': + if(poiData.museum) newPoi.tooltip = '

Museum Office

'; + else if(poiData.military) newPoi.tooltip = '

Military Office

'; + else newPoi.tooltip = '

Office

'; + break; + default: + newPoi.tooltip = `

${newPoi.name}

${poiData.description ? '
'+poiData.description : ''}`; + newPoi.minZoom = 0.07; + break; + } + + markers.push(newPoi); + if(poiData.children){ + for(let child of poiData.children){ + addPoi(child, level+1); + } + } +} + +export function generateTrackSignage(){ + let yardMarkers = {}; + for(const railTrack of railTracks){ + let trackZoneData = {}; + let lastCurve = false; + + let resetTzdGrade = (i=0) => { + trackZoneData.gradeClass = railTrack.curves[i].gradeClass; + trackZoneData.gradeDirection = Math.sign(railTrack.curves[i].grade); + trackZoneData.gradeZoneStart = i; + trackZoneData.gradeZoneLength = 0; + }; + let resetTzdSpeed = (i=0) => { + trackZoneData.postedSpeed = railTrack.curves[i].postedSpeed; + trackZoneData.speedZoneStart = i; + trackZoneData.speedZoneLength = 0; + }; + + resetTzdGrade(); + resetTzdSpeed(); + + for(let i=0; i Config.maxZoneLength + || lastCurve){ + if(railTrack.curves[i].gradeClass != 'flat'){ + let markerIndex = Math.ceil((trackZoneData.gradeZoneStart + i)/2); + let markerOffset = (trackZoneData.gradeZoneStart + i) % 2 ? 0.1 : 0.6; + let b1 = railTrack.curves[markerIndex].start; + let b2 = railTrack.curves[markerIndex].h1; + let b3 = railTrack.curves[markerIndex].h2; + let b4 = railTrack.curves[markerIndex].end; + + let newMarker = { + type: 'grade', + value: railTrack.curves[markerIndex].grade, + position: Bezier.evaluatePoint(b1, b2, b3, b4, markerOffset), + gradeClass: railTrack.curves[markerIndex].gradeClass, + minZoom: Math.max(0.02, Math.min(1.0, 20/trackZoneData.gradeZoneLength)), + zoneLength: trackZoneData.gradeZoneLength, + zoneCount: i - trackZoneData.gradeZoneStart + 1, + tooltipHitzone: {radius:16}, + } + let tangent = newMarker.value > 0 + ? Bezier.evaluateVelocity(b1, b2, b3, b4, markerOffset) + : Bezier.evaluateVelocity(b4, b3, b2, b1, markerOffset); + let tangentLength = Math.sqrt(tangent.x * tangent.x + tangent.z * tangent.z) * Math.sign(tangent.y); + newMarker.rotation = Math.atan2(tangent.x/tangentLength, tangent.z/tangentLength); + newMarker.tooltip = `

Grade

${Math.round(newMarker.value*1000)/10}% Grade\nUphill bearing ${Utils.mod(newMarker.rotation/(Math.PI*2)*360, 360).toFixed(0)}, ${Utils.angleToCardinalDirection(newMarker.rotation)}\nSection Length: ${newMarker.zoneLength.toFixed(1)} meters\nCovers ${newMarker.zoneCount} track sections`; + markers.push(newMarker); + } + resetTzdGrade(nextIndex); + } + //Speed Markers + trackZoneData.speedZoneLength += railTrack.curves[i].length; + if(railTrack.curves[nextIndex].postedSpeed != trackZoneData.postedSpeed + || trackZoneData.speedZoneLength > Config.maxZoneLength + || lastCurve){ + let markerIndex = Math.ceil((trackZoneData.speedZoneStart + i)/2); + let markerOffset = (trackZoneData.speedZoneStart + i) % 2 ? 0.2 : 0.7; + let b1 = railTrack.curves[markerIndex].start; + let b2 = railTrack.curves[markerIndex].h1; + let b3 = railTrack.curves[markerIndex].h2; + let b4 = railTrack.curves[markerIndex].end; + + let newMarker = { + type: 'speed', + value: railTrack.curves[markerIndex].postedSpeed, + position: Bezier.evaluatePoint(b1, b2, b3, b4, markerOffset), + zoneLength: trackZoneData.speedZoneLength, + zoneCount: i - trackZoneData.speedZoneStart + 1, + minZoom: 0.05, + } + if((railTrack.isYard || railTrack.isJunction) && newMarker.value > 30){ + if(newMarker.value > 40) newMarker.minZoom = 5; + else newMarker.minZoom = 0.5; + } + if(newMarker.value > 80) newMarker.minZoom *= 6.5; + else if(newMarker.value > 60) newMarker.minZoom *= 5; + else if(newMarker.value > 40) newMarker.minZoom *= 3; + + if(newMarker.zoneLength > 500) newMarker.minZoom *= 0.15; + + if(newMarker.zoneLength < 35 && newMarker.value > 40) newMarker.minZoom *= 6; + + newMarker.tooltip = `

Speed

${Math.round(newMarker.value)} km/h\nSection Length: ${newMarker.zoneLength.toFixed(1)} meters\nCovers ${newMarker.zoneCount} track sections`; + markers.push(newMarker); + resetTzdSpeed(nextIndex); + } + } + + if(railTrack.yardDesignation && railTrack.yardDesignation != '--'){ + if(!yardMarkers[railTrack.yardStation]) yardMarkers[railTrack.yardStation] = {}; + let centerIndex = Math.floor(railTrack.curves.length/2); + let yardSignPos = railTrack.curves[centerIndex].start; + if(railTrack.curves.length % 2 == 1){ + let curve = railTrack.curves[centerIndex]; + yardSignPos = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, 0.5); + } + if(railTrack.yardName){ + if(!yardMarkers[railTrack.yardStation][railTrack.yardName]) + yardMarkers[railTrack.yardStation][railTrack.yardName] = {accCenter:{x:0,y:0,z:0},accCount:0}; + yardMarkers[railTrack.yardStation][railTrack.yardName].accCenter.x += yardSignPos.x; + yardMarkers[railTrack.yardStation][railTrack.yardName].accCenter.y += yardSignPos.y; + yardMarkers[railTrack.yardStation][railTrack.yardName].accCenter.z += yardSignPos.z; + yardMarkers[railTrack.yardStation][railTrack.yardName].accCount++; + } + let newMarker = { + type: 'yardSiding', + name: railTrack.yardDesignation, + position: yardSignPos, + minZoom: 2.5, + }; + if(railTrack.yardName && railTrack.yardSidingNumber && railTrack.yardUsage){ + if(!Config.sidingUsageMeanings[railTrack.yardUsage]) console.log(`Unknown siding usage for ${railTrack.yardDesignation} at ${railTrack.yardStation}`); + newMarker.tooltip = `

Siding

${railTrack.yardDesignation}\n${Config.sidingUsageMeanings[railTrack.yardUsage]}`; + }else{ + newMarker.tooltip = `

Siding

${railTrack.yardDesignation}`; + } + markers.push(newMarker); + } + } + + for(let station in yardMarkers){ + for(let yard in yardMarkers[station]){ + markers.push({ + type: 'yard', + name: `${yard} Yard`, + position: { + x: yardMarkers[station][yard].accCenter.x/yardMarkers[station][yard].accCount, + y: yardMarkers[station][yard].accCenter.y/yardMarkers[station][yard].accCount, + z: yardMarkers[station][yard].accCenter.z/yardMarkers[station][yard].accCount, + }, + tooltip: `

${yard} Yard

`, + maxZoom: 2.5, + minZoom: 0.25, + }); + } + } +} + +/** + * Finds the relevant tooltip for where the cursor is (if possible) + * Returns "" if there is none. + * @param {Number} cursorX + * @param {Number} cursorY + * @returns {String} + */ +export function testTooltip(cursorX, cursorY){ + cursorX = view.unconvertX(cursorX); + cursorY = view.unconvertY(cursorY); + + let finalTooltip = ''; + let radius = 0; + let width = 0; + let height = 0; + for(const marker of markers){ + if(!marker.visible) continue; + if(marker.boundsCheck != null){ + if(marker.boundsCheck(cursorX, cursorY)) + finalTooltip = marker.tooltip ?? finalTooltip; + }else if(marker.tooltipHitzone && marker.tooltipHitzone.width && marker.tooltipHitzone.height){ + width = marker.tooltipHitzone.width * 0.5/view.scale; + height = marker.tooltipHitzone.height * 0.5/view.scale; + if( marker.position.x - cursorX + (marker.tooltipHitzone.offsetX ?? 0)/view.scale <= width + && marker.position.x - cursorX + (marker.tooltipHitzone.offsetX ?? 0)/view.scale >= -width + && marker.position.z - cursorY + (marker.tooltipHitzone.offsetY ?? 0)/view.scale <= height + && marker.position.z - cursorY + (marker.tooltipHitzone.offsetY ?? 0)/view.scale >= -height){ + finalTooltip = marker.tooltip ?? finalTooltip; + } + }else{ + if(marker.tooltipHitzone) + radius = (marker.tooltipHitzone.radius ?? Config.tooltipHitzone.default.radius) / view.scale; + else + radius = Config.tooltipHitzone.default.radius / view.scale; + if(Math.pow(marker.position.x - cursorX, 2) + Math.pow(marker.position.z - cursorY, 2) <= radius * radius){ + finalTooltip = marker.tooltip ?? finalTooltip; + } + } + } + if(finalTooltip != '') return finalTooltip; + + let bestDistance = Infinity; + let test = {}; + let boundsPadding = 40/view.scale; + for(const railTrack of railTracks){ + if(cursorX >= railTrack.bounds.min.x - boundsPadding && cursorX <= railTrack.bounds.max.x + boundsPadding && cursorY >= railTrack.bounds.min.z - boundsPadding && cursorY <= railTrack.bounds.max.z + boundsPadding){ + for(const curve of railTrack.curves){ + if(cursorX >= curve.bounds.min.x - boundsPadding && cursorX <= curve.bounds.max.x + boundsPadding && cursorY >= curve.bounds.min.z - boundsPadding && cursorY <= curve.bounds.max.z + boundsPadding){ + test = Bezier.getNearestPoint2D(curve.start, curve.h1, curve.h2, curve.end, {x:cursorX, y:cursorY}, Math.floor(curve.length*2)); + if(test.distance < bestDistance){ + bestDistance = test.distance; + finalTooltip = curve.tooltip; + testPoint = test.point; + } + } + } + } + } + + return bestDistance <= Math.min(Math.max(0.9, 12/view.scale), 120) ? finalTooltip : ''; +} + +export function setTrackColorMode(mode){ + let modeFunction; + const keyElement = document.getElementById('legendKeyColors'); + let gradientString = ''; + keyElement.innerHTML = ''; + switch(mode){ + case 'Track Type': + modeFunction = (curve, railTrack) => { + let finalColor = '#aaa'; + if(railTrack.isYard && railTrack.yardDesignation) finalColor = '#4bf'; + else if(railTrack.isYard) finalColor = '#4fb'; + else if(railTrack.isJunction) finalColor = '#fb4'; + else if(railTrack.isTurntable) finalColor = '#b4f'; + + curve.color = finalColor; + }; + keyElement.innerHTML = ` + Designated Yard + Other Yard + Point + Turntable + Other Tracks`; + break; + case 'Grade': + modeFunction = curve => { + curve.color = Config.gradeColors['grade_'+curve.gradeClass]; + }; + for(let grade in Config.gradeColors){ + keyElement.innerHTML += `${(Utils.classToGrade(grade.replace('grade_',''))*100).toFixed(1)}%`; + } + break; + case 'Altitude': + modeFunction = (curve, railTrack) => { + curve.color = Color.blendGradient(Config.altitudeGradient, (curve.averageHeight-matrix.minAlt)/(matrix.maxAlt-matrix.minAlt)).hex; + } + gradientString = 'linear-gradient(to right'; + for(let col of Config.altitudeGradient) gradientString += ', '+col.hex; + gradientString += ')'; + + keyElement.innerHTML = ` +
+
${Math.floor(matrix.minAlt)}m
+
+
${Math.ceil(matrix.maxAlt)}m
+
+ `; + break; + case 'Speed': + modeFunction = curve => { + curve.color = Color.blendGradient(Config.speedGradient, (curve.postedSpeed+10)/110).hex; + }; + + gradientString = 'linear-gradient(to right'; + for(let col of Config.speedGradient) gradientString += ', '+col.hex; + gradientString += ')'; + keyElement.innerHTML = ` +
+
10 kmh
+
+
120 kmh
+
+ `; + break; + case 'Track Random': + modeFunction = (curve, railTrack) => { + curve.color = railTrack.randomColor.hex; + }; + keyElement.innerHTML = 'Random per continuous track section'; + break; + case 'Curve Random': + modeFunction = curve => { + curve.color = curve.randomColor.hex; + }; + keyElement.innerHTML = 'Random per individual track bezier curve'; + break; + default: + modeFunction = curve => { + curve.color = '#fff'; + }; + break; + } + if(modeFunction != null){ + for(let railTrack of railTracks){ + for(let curve of railTrack.curves){ + modeFunction(curve, railTrack); + } + } + } + view.dirty = true; +} \ No newline at end of file diff --git a/js/spritelib.js b/js/spritelib.js new file mode 100644 index 0000000..1f5f815 --- /dev/null +++ b/js/spritelib.js @@ -0,0 +1,10 @@ +'use strict'; + +export class SpriteBounds{ + constructor(x, y, width, height){ + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } +} \ No newline at end of file diff --git a/js/utillib.js b/js/utillib.js index 04b0f4f..0e230d4 100644 --- a/js/utillib.js +++ b/js/utillib.js @@ -1,5 +1,8 @@ 'use strict'; +import * as Config from './../config.js'; +import {SpriteBounds} from './spritelib.js'; + export const gradeIncrements = [ 0.0498, 0.04, @@ -11,7 +14,60 @@ export const gradeIncrements = [ 0.01, 0.005, 0.001 -] +]; + +const gradeArrowDimensions = 64; +export const gradeArrows = new OffscreenCanvas(11*gradeArrowDimensions, 2*gradeArrowDimensions); + +export function generateGradeArrows(){ + const ctx = gradeArrows.getContext('2d'); + const mapSprites = document.getElementById('mapSprites'); + for(let i=0; i<11; i++){ + ctx.drawImage( + mapSprites, + Config.spriteBounds.gradeArrow.x, + Config.spriteBounds.gradeArrow.y, + Config.spriteBounds.gradeArrow.width, + Config.spriteBounds.gradeArrow.height, + i*gradeArrowDimensions, + 0, + gradeArrowDimensions, + gradeArrowDimensions + ); + ctx.drawImage( + mapSprites, + Config.spriteBounds.gradeArrowDown.x, + Config.spriteBounds.gradeArrowDown.y, + Config.spriteBounds.gradeArrowDown.width, + Config.spriteBounds.gradeArrowDown.height, + i*gradeArrowDimensions, + gradeArrowDimensions, + gradeArrowDimensions, + gradeArrowDimensions + ); + + if(i == 0){ + Config.spriteBounds['gradeArrow_flat'] = new SpriteBounds(0, 0, gradeArrowDimensions, gradeArrowDimensions); + Config.spriteBounds['gradeArrowDown_flat'] = new SpriteBounds(0, gradeArrowDimensions, gradeArrowDimensions, gradeArrowDimensions); + }else{ + Config.spriteBounds['gradeArrow_'+(i-1)] = new SpriteBounds(i*gradeArrowDimensions, 0, gradeArrowDimensions, gradeArrowDimensions); + Config.spriteBounds['gradeArrowDown_'+(i-1)] = new SpriteBounds(i*gradeArrowDimensions, gradeArrowDimensions, gradeArrowDimensions, gradeArrowDimensions); + } + } + let arrowLayer = gradeArrows.transferToImageBitmap(); + for(let i=0; i<11; i++){ + if(i == 0) + ctx.fillStyle = Config.gradeColors.grade_flat; + else + ctx.fillStyle = Config.gradeColors['grade_'+(i-1)]; + ctx.fillRect(i*gradeArrowDimensions, 0, gradeArrowDimensions, gradeArrowDimensions * 2); + } + ctx.globalCompositeOperation = 'multiply'; + ctx.drawImage(arrowLayer, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(arrowLayer, 0, 0); + arrowLayer.close(); +} export function randomRange(min=0.0, max=1.0){ return Math.random()*(max-min) + min; @@ -74,4 +130,45 @@ export function radiusToSpeed(radius){ export function clamp(v, min = 0, max = 1){ return Math.max(Math.min(v, max), min); +} + +export function zoomToCullLevel(zoom){ + if(zoom < 1) return 0; + if(zoom < 2) return 1; + if(zoom < 3) return 2; + if(zoom < 6) return 3; + if(zoom < 12) return 4; + if(zoom < 24) return 5; + if(zoom < 60) return 6; + return 7; +} + +export function lerp(a, b, t){ + return (a * (1-t)) + (b*t); +} + +export function mod(a, b){ + return (a % b + b) % b; +} + +export function titleCase(text){ + text = text.toString(); + if(text){ + return `${text[0].toUpperCase()}${text.substring(1)}`; + } + return ''; +} + +const cardinalDirections = [ + 'North', + 'Northeast', + 'East', + 'Southeast', + 'South', + 'Southwest', + 'West', + 'Northwest', +] +export function angleToCardinalDirection(angle){ + return cardinalDirections[Math.floor(mod(angle/(2*Math.PI) + (0.5/cardinalDirections.length), 1)*cardinalDirections.length)]; } \ No newline at end of file diff --git a/legend.js b/legend.js index b62bc90..e4ba3f0 100644 --- a/legend.js +++ b/legend.js @@ -3,7 +3,7 @@ import * as Utils from './js/utillib.js'; import * as Config from './config.js'; import { Color } from './js/colorlib.js'; -import { setTrackColorMode } from './index.js'; +import * as MapData from './js/mapdata.js'; let settingEntries = [ { @@ -18,9 +18,9 @@ let settingEntries = [ 'Track Random', 'Curve Random', ], - default: 'Grade', + state: 'Grade', func: state =>{ - setTrackColorMode(state); + MapData.setTrackColorMode(state); } }, { @@ -28,15 +28,17 @@ let settingEntries = [ id: 'toggle_signage', state: true, func: state =>{ - document.getElementById('signage_container').style.display = state ? 'unset' : 'none'; + MapData.layers.signage = state; + MapData.view.dirty = true; }, children: [ { - label: 'Speed Signs', + label: 'Speed Signage', id: 'toggle_speedSigns', state: true, func: state =>{ - document.getElementById('signage_speed').style.display = state ? 'unset' : 'none'; + MapData.layers.speedSigns = state; + MapData.view.dirty = true; }, }, { @@ -44,7 +46,8 @@ let settingEntries = [ id: 'toggle_gradeSigns', state: true, func: state =>{ - document.getElementById('signage_grade').style.display = state ? 'unset' : 'none'; + MapData.layers.gradeArrows = state; + MapData.view.dirty = true; }, }, { @@ -52,15 +55,17 @@ let settingEntries = [ id: 'toggle_yardSigns', state: true, func: state =>{ - document.getElementById('signage_yard').style.display = state ? 'unset' : 'none'; + MapData.layers.yardSigns = state; + MapData.view.dirty = true; }, }, { label: 'Junctions', id: 'toggle_junctions', - state: false, + state: true, func: state =>{ - document.getElementById('signage_junction').style.display = state ? 'unset' : 'none'; + MapData.layers.junctions = state; + MapData.view.dirty = true; }, } ], @@ -70,15 +75,26 @@ let settingEntries = [ id: 'toggle_poi', state: true, func: state => { - document.getElementById('poi_container').style.display = state ? 'unset' : 'none'; + MapData.layers.poi = state; + MapData.view.dirty = true; }, children: [ + { + label: 'Stations', + id: 'toggle_stations', + state: true, + func: state =>{ + MapData.layers.stations = state; + MapData.view.dirty = true; + } + }, { label: 'Services', id: 'toggle_services', state: true, func: state =>{ - document.getElementById('poi_service').style.display = state ? 'unset' : 'none'; + MapData.layers.services = state; + MapData.view.dirty = true; }, }, { @@ -86,7 +102,8 @@ let settingEntries = [ id: 'toggle_landmarks', state: true, func: state =>{ - document.getElementById('poi_landmarks').style.display = state ? 'unset' : 'none'; + MapData.layers.landmarks = state; + MapData.view.dirty = true; }, }, ] @@ -98,19 +115,44 @@ let settingEntries = [ 'Uphill', 'Downhill', ], - default: 'Uphill', + state: 'Uphill', func: state =>{ - let newHref = state == 'Uphill' ? '#gradeArrow' : '#gradeArrowDownhill'; - for(let arrow of document.getElementsByClassName('gradeArrow')){ - arrow.setAttribute('href', newHref); - } + MapData.layers.gradeDirection = state == 'Downhill' ? 'Down' : ''; + MapData.view.dirty = true; } }, + { + label: 'Terrain', + id: 'dropdown_terrain', + options: [ + 'None', + 'Simple', + 'DVRT', + ], + state: 'None', + func: state =>{ + let mapTerrain = document.getElementById('mapTerrain'); + if(state == 'None'){ + MapData.layers.terrain = false; + MapData.view.dirty = true; + }else{ + mapTerrain.onload = () => { + MapData.layers.terrain = true; + MapData.view.dirty = true; + } + if(state == 'Simple'){ + document.getElementById('mapTerrain').src = 'terrains/simple.jpg'; + }else if(state == 'DVRT'){ + document.getElementById('mapTerrain').src = 'terrains/dvrt.jpg'; + } + } + } + } ]; const legendKey = document.getElementById('legendKey'); -document.addEventListener('DOMContentLoaded', async () => { +export function initialize(){ const settingsPanel = document.getElementById('settingsPanel'); for(let thisSetting of settingEntries){ addSettingEntry(thisSetting, settingsPanel); @@ -130,7 +172,7 @@ document.addEventListener('DOMContentLoaded', async () => { if(document.body.clientWidth <= 700) legendButtonEvent(); populateKey(); setLastUpdateData(); -}); +}; function addSettingEntry(thisSetting, parent, indent=0){ thisSetting.divContainer = document.createElement('div'); @@ -166,7 +208,7 @@ function addSettingEntry(thisSetting, parent, indent=0){ optionElement.value = inputEntry; optionElement.innerHTML = inputEntry; } - if(inputEntry == thisSetting.default) optionElement.selected = true; + if(inputEntry == thisSetting.state) optionElement.selected = true; thisSetting.inputElement.appendChild(optionElement); } if(thisSetting.func){ @@ -199,6 +241,10 @@ function addSettingEntry(thisSetting, parent, indent=0){ thisSetting.inputElement.style.margin = `0 0.5em 0 ${indent}em`; thisSetting.divContainer.prepend(thisSetting.inputElement); + if(thisSetting.func){ + thisSetting.func(thisSetting.state); + } + if(thisSetting.children){ for(let child of thisSetting.children){ addSettingEntry(child, parent, indent+1); @@ -208,11 +254,16 @@ function addSettingEntry(thisSetting, parent, indent=0){ let pageLoadUpdateDate = null; async function setLastUpdateData(){ + if(!window.location.host.includes('github')) return; let commits = await (await fetch(new Request('https://api.github.com/repos/PyroNicampt/DV-Community-Map/commits'))).json(); let lastUpdateElement = document.getElementById('lastUpdate'); + if(!commits){ + lastUpdateElement.innerHTML = 'Fetch Error'; + return; + }; let updateDate = new Date(commits[0].commit.author.date); - - lastUpdateElement.innerHTML = `${Utils.formattedTimeBetweenDates(new Date(), updateDate)} ago`; + lastUpdateElement.innerHTML = `${Utils.formattedTimeBetweenDates(new Date(), updateDate)} ${new Date() >= updateDate ? 'ago' : 'from now'}`; + document.getElementById('changelog').innerHTML = commits[0].commit.message.replace('\n','
'); if(pageLoadUpdateDate == null){ pageLoadUpdateDate = updateDate; }else{ @@ -221,38 +272,24 @@ async function setLastUpdateData(){ } } - setTimeout(() => {setLastUpdateData()}, 900000); + setTimeout(() => {setLastUpdateData()}, 600000); } function populateKey(){ - const getGradeValue = gradeIndex => {return Math.round(Utils.gradeIncrements[Utils.gradeIncrements.length-gradeIndex-1]*10000)/100}; - for(let i=-1; i=0){ - gradeValue = `${getGradeValue(i)}`; - gradeIndex = i; - } - if(i == Utils.gradeIncrements.length-1){ - gradeValue = '> '+gradeValue+'%'; - }else if(i >= 0){ - gradeValue += ` - ${getGradeValue(i+1)}%`; - } - addKeyEntry(``, ` ${gradeValue} Grade`); - } - addKeyEntry('', ' Speed Limit'); - addKeyEntry('', ' Junction/Switch'); - addKeyEntry('', ' Station Office'); - addKeyEntry('', ' Shop'); - addKeyEntry('', ' Repair Service'); - addKeyEntry('', ' Diesel Refuel'); - addKeyEntry('', ' Electric Charger'); - addKeyEntry('', ' Coal Tower'); - addKeyEntry('', ' Water Tower'); - addKeyEntry('', ' Landmark'); - addKeyEntry('', ' Garage'); + addKeyEntry('gradeArrow', 'Grade Indication') + addKeyEntry('speed_5', 'Speed Limit'); + addKeyEntry('junction', 'Junction/Switch'); + addKeyEntry('office', 'Station Office'); + addKeyEntry('shop', 'Shop'); + addKeyEntry('service_repair', 'Repair Service'); + addKeyEntry('service_diesel', 'Diesel Refuel'); + addKeyEntry('service_charger', 'Electric Charger'); + addKeyEntry('coal', 'Coal Tower'); + addKeyEntry('water', 'Water Tower'); + addKeyEntry('landmark', 'Landmark'); + addKeyEntry('garage', 'Garage'); //Add empty entries to make entries align right in the columns. - for(let i=0; i<2; i++){ + for(let i=0; i<3; i++){ addKeyEntry(); } } @@ -262,15 +299,17 @@ function populateKey(){ * @param {string} keyImageHTML * @param {string} keyLabel */ -function addKeyEntry(keyImageHTML, keyLabel){ +function addKeyEntry(keySprite, keyLabel){ if(!keyLabel) keyLabel = ' '; let keyDiv = document.createElement('div'); - if(keyImageHTML){ - let keyImage = document.createElement('svg'); - keyImage.innerHTML = keyImageHTML; + if(keySprite && Config.spriteBounds[keySprite]){ + let keyImage = document.createElement('span'); + let spriteBounds = Config.spriteBounds[keySprite]; keyImage.classList.add('inlineSvg'); - keyImage.setAttribute('viewBox', '-150 -150 300 300'); + keyImage.style.background = `url('mapSprites.svg') -${spriteBounds.x/spriteBounds.width}em -${spriteBounds.y/spriteBounds.height}em`; + keyImage.style.backgroundSize = '1000%'; keyDiv.appendChild(keyImage); + keyLabel = ' '+keyLabel; } keyDiv.innerHTML += keyLabel; legendKey.appendChild(keyDiv); diff --git a/mapSprites.svg b/mapSprites.svg new file mode 100644 index 0000000..c775a45 --- /dev/null +++ b/mapSprites.svg @@ -0,0 +1,282 @@ + + + + diff --git a/poi_dv.json b/poi_dv.json index 0005ff4..58d3fa8 100644 --- a/poi_dv.json +++ b/poi_dv.json @@ -107,6 +107,7 @@ { "name":"Olaf's Garage", "type":"garage", + "description":"DM1U-150 Utility Rail Vehicle\nKey sold at Machine Factory's Shop", "position":{"x":10448.56,"y":134.9,"z":1331.074} } ] @@ -135,6 +136,39 @@ }, { "type":"shop", + "stock":[ + "Boombox", + "Red Beacon", + "Plastic Crate", + "Cassette A7", + "Cassette P6", + "Cassette P9", + "Red Light Bar", + "File Box", + "Purple Flag Marker", + "Reginald's Garage Key", + "Cassette P10", + "Cassette P5", + "Cassette P7", + "Handheld Game Console", + "Wooden Crate", + "Cassette A1", + "Cassette P2", + "Yellow Flag Marker", + "Green Flag Marker", + "Orange Flag Marker", + "Cyan Flag Marker", + "Cassette P4", + "Green Light Bar", + "Cassette P1", + "Blue Flag Marker", + "Cassette P8", + "White Flag Marker", + "Red Flag Marker", + "Cassette A2", + "Blue Beacon", + "Cassette P3" + ], "position":{"x":1911.48,"y":122.26,"z":5788.65} } ] @@ -164,6 +198,13 @@ }, { "type":"shop", + "stock":[ + "Cassette A12", + "Cassette A15", + "Cassette A6", + "Cassette A3", + "Old Bob's Garage Key" + ], "position":{"x":9528.669,"y":119.3432,"z":13419.99} } ] @@ -248,6 +289,30 @@ }, { "type":"shop", + "stock":[ + "Shelf", + "Rotary Controller", + "Analog Controller", + "Cassette A16", + "Crimping Tool", + "Blue Light Bar", + "Brake Cylinder LED Bar", + "Electric Hand Drill", + "Digital Speedometer", + "Golden Shovel", + "Infrared Thermometer", + "Button Controller", + "Filler Gun", + "Sun Visor", + "Cassette A10", + "Expert Shovel", + "Digital Clock", + "Alternating Controller", + "White Light Bar", + "Soldering Gun", + "Clinometer", + "Cassette A8" + ], "position":{"x":13035.07,"y":140.1661,"z":11158.79} } ] @@ -286,6 +351,20 @@ }, { "type":"shop", + "stock":[ + "Plastic Crate", + "Steve's Garage Key", + "Wooden Crate", + "Red Beacon", + "Steam Locomotive Checklist", + "File Box", + "Cassette A11", + "Purple Light Bar", + "Cassette A4", + "Pocketwatch", + "Blue Beacon", + "Cyan Light Bar" + ], "position":{"x":13423.26,"y":113.1099,"z":3611.852} } ] @@ -396,6 +475,30 @@ }, { "type":"shop", + "stock":[ + "Amber Beacon", + "Headlight", + "Button Controller", + "Automatic Train Stop", + "Analog Controller", + "Cassette A13", + "Rotary Controller", + "Locomotive Remote", + "Alternating Controller", + "Olaf's Garage Key", + "Demonstrator Paint Can", + "Yellow Light Bar", + "Wireless MU Controller", + "Anti-Wheelslip Computer", + "Overheat Protection", + "Cassette A14", + "Orange Light Bar", + "Cassette A9", + "Distance Tracker", + "Amp Limiter", + "Switch Setter", + "Cassette A5" + ], "position":{"x":2236.745,"y":159.266,"z":10834.23} } ] @@ -525,16 +628,19 @@ { "name":"Old Bob's Garage", "type":"garage", + "description":"BE2 Microshunter\nKey sold at Food Factory's Shop", "position":{"x":1590.981,"y":137.4275,"z":12227.22} }, { "name":"Reginald's Garage", "type":"garage", + "description":"Caboose\nKey sold at City West's Shop", "position":{"x":4387.325,"y":147.84,"z":2458.901} }, { "name":"Steve's Garage", "type":"garage", + "description":"DE6 Slug\nKey sold at Harbor's Shop", "position":{"x":14527.17,"y":163.7,"z":2365.416} }, { diff --git a/style.css b/style.css index c8d4dc0..9ba6e73 100644 --- a/style.css +++ b/style.css @@ -42,6 +42,11 @@ a:visited{ pointer-events: all; } +.panel hr { + border: 0px; + border-bottom: 2px solid #b3b3b396; +} + #topleft{ position:fixed; top: 0px; @@ -182,4 +187,39 @@ button{ width: var(--legendButtonSize); height: var(--legendButtonSize); } +} + +.signText { + font-family: Arial; +} + +#changelog { + width: fit-content; + font-size: 11px; +} + +#tooltip{ + position: fixed; + left: 0px; + top: 0px; + pointer-events: none; + padding: 5px; + margin: 0; + font-size: 12px; +} +#tooltip h1{ + font-variation-settings: "wght" 600; + font-style: italic; + font-size: 15px; + line-height: 1em; + padding-bottom: 4px; + padding: 0 4px 4px 4px; +} +#tooltip h2{ + font-variation-settings: "wght" 600; + font-size: 10px; + line-height: 1em; + padding-bottom: 4px; + opacity: 0.7; + border-bottom: 2px solid #b3b3b396; } \ No newline at end of file diff --git a/terrains/dvrt.jpg b/terrains/dvrt.jpg new file mode 100644 index 0000000..d577d2d Binary files /dev/null and b/terrains/dvrt.jpg differ diff --git a/terrains/simple.jpg b/terrains/simple.jpg new file mode 100644 index 0000000..35777cd Binary files /dev/null and b/terrains/simple.jpg differ