diff --git a/LICENSE_leaflet_control_window.txt b/LICENSE_leaflet_control_window.txt new file mode 100644 index 0000000..a9aa88a --- /dev/null +++ b/LICENSE_leaflet_control_window.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015, Filip Zavadil +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/main/kotlin/littlechasiu/ctm/Extensions.kt b/src/main/kotlin/littlechasiu/ctm/Extensions.kt index 571889d..56ce537 100644 --- a/src/main/kotlin/littlechasiu/ctm/Extensions.kt +++ b/src/main/kotlin/littlechasiu/ctm/Extensions.kt @@ -1,11 +1,18 @@ package littlechasiu.ctm import com.simibubi.create.content.trains.entity.Carriage +import com.simibubi.create.content.trains.entity.Navigation import com.simibubi.create.content.trains.entity.Train import com.simibubi.create.content.trains.entity.TravellingPoint import com.simibubi.create.content.trains.graph.TrackEdge +import com.simibubi.create.content.trains.graph.TrackGraph import com.simibubi.create.content.trains.graph.TrackNode import com.simibubi.create.content.trains.graph.TrackNodeLocation +import com.simibubi.create.content.trains.schedule.ScheduleRuntime +import com.simibubi.create.content.trains.schedule.destination.ChangeThrottleInstruction +import com.simibubi.create.content.trains.schedule.destination.ChangeTitleInstruction +import com.simibubi.create.content.trains.schedule.destination.DestinationInstruction +import com.simibubi.create.content.trains.station.GlobalStation import com.simibubi.create.foundation.utility.Couple import littlechasiu.ctm.model.* import net.minecraft.resources.ResourceKey @@ -44,6 +51,7 @@ val TrackNode.dimensionLocation: DimensionLocation get() = DimensionLocation(location.dimension.string, location.sendable) +@Suppress("IMPLICIT_CAST_TO_ANY") val TrackEdge.sendable get() = if (isInterDimensional) @@ -75,13 +83,195 @@ val Carriage.sendable }, ) -val Train.sendable - get() = - CreateTrain( - id = id, - name = name.string, - owner = null, - cars = carriages.map { it.sendable }.toList(), - backwards = speed < 0, - stopped = speed == 0.0, +val ScheduleRuntime.sendable + get() = schedule?.let { + val field = ScheduleRuntime::class.java.getDeclaredField("ticksInTransit") + field.isAccessible = true + val currentTime = field.get(this) as Int + + + CreateSchedule( + cycling = it.cyclic, + instructions = getInstructions(this), + paused = paused, + currentEntry = currentEntry, + ticksInTransit = currentTime, + ) + } + +/** + * gets schedule instructions for a train + * @param scheduleRuntime ScheduleRuntime object associated with the Train + * @return ArrayList result + */ +fun getInstructions(scheduleRuntime: ScheduleRuntime): ArrayList { + val result: ArrayList = ArrayList() + val instructions = scheduleRuntime.schedule.entries + + val field = ScheduleRuntime::class.java.getDeclaredField("predictionTicks") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val predictionTicks = field.get(scheduleRuntime) as List + + instructions.forEachIndexed { i, entry -> + when (val instruction = entry.instruction) { + is DestinationInstruction -> { + val stationName = instruction.summary.second.string + result.add(ScheduleInstructionDestination(stationName = stationName, ticksToComplete = predictionTicks[i])) + } + is ChangeTitleInstruction -> { + val newName = instruction.scheduleTitle + result.add(ScheduleInstructionNameChange(newName = newName)) + } + is ChangeThrottleInstruction -> { + val throttle = instruction.summary.second.string + result.add(ScheduleInstructionThrottleChange(throttle = throttle)) + } + } + } + return result +} + +val Train.sendable: CreateTrain + get() { + return CreateTrain( + id = id, + name = name.string, + owner = null, + cars = carriages.map { it.sendable }.toList(), + speed = speed, + backwards = speed < 0, + stopped = speed == 0.0, + schedule = runtime.sendable, + currentPath = getCurrentTrainPath(navigation), ) + } + +/*Path calculation methods*/ + +/** + * Calculates next TrackEdge when the train isn't turning. + * @param graph TrackGraph the train is currently on. + * @param trackEdge TrackEdge the train is currently on. + * @param trackNode TrackNode the train is driving towards + * @param direction Vec3 direction where the track / train is pointing + * @return TrackEdge result + */ +private fun getNextEdge(graph: TrackGraph, trackEdge: TrackEdge, trackNode: TrackNode, direction: Vec3) : TrackEdge?{ + var result : TrackEdge? = null + var biggest = Double.MIN_VALUE + graph.getConnectionsFrom(trackNode).forEach { key, value -> + if (key == trackEdge) { + return@forEach + } + val dot = value.getDirection(true).dot(direction) + if(dot > biggest && dot > 0){ + biggest = dot + result = value + } + } + return result +} + +/** + * Calculates path when the train keeps going without turning. + * This is not a pathfinder endNode must be reachable with no turns. + * Method stops after 100 iterations by itself + * @param startEdge TrackEdge start point + * @param endNode TrackEdge end point + * @param graph TrackGraph the train is currently on + * @return ArrayList result + */ +private fun straightLinePathToEndNode(startEdge: TrackEdge, endNode: TrackNode, graph: TrackGraph) : ArrayList { + val result = ArrayList() + + var tEdge: TrackEdge = startEdge + + var direction: Vec3 + var reachedEnd = false + val MAX_PREDICTIONS = 100 + + var j = 0 + while (!reachedEnd) { + direction = tEdge.getDirection(false) + tEdge = getNextEdge(graph, tEdge, tEdge.node2, direction) ?: return result + + j++ + if (tEdge.node1.netId == endNode.netId) { + reachedEnd = true // incase tEdge is the next scheduled edge + } else { + val edge = tEdge.sendable + if(edge is Edge) { // incase edge is portal, then ignore it + result.add(edge) + } + } + + if (tEdge.node2.netId == endNode.netId || j > MAX_PREDICTIONS) { + reachedEnd = true + } + } + return result +} + +/** + * Gets the TrackEdge a train station + * @param station GlobalStation train station + * @param graph TrackGraph the train station is on + * @return TrackEdge result + */ +private fun getEdgeFromStation(station: GlobalStation, graph: TrackGraph) : TrackEdge { + val firstNode = graph.locateNode(station.edgeLocation.first) + val secondNode = graph.locateNode(station.edgeLocation.second) + return graph.getConnection(Couple.create(firstNode, secondNode)) +} + +/** + * Calculates the current path the train is taking + * @param navigation Navigation object of Train + * @return Path information object + */ +private fun getCurrentTrainPath(navigation: Navigation?) : Path{ + val graph = navigation?.train?.graph + val result : ArrayList = ArrayList() + if(!CreateTrain.enableNavigationTracks || navigation == null || graph == null || navigation.destination == null){ + return Path(result,0.0,0.0) + } + val field = Navigation::class.java.getDeclaredField("currentPath") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val currentPath = field.get(navigation) as List> + + val endPointEdge = if(navigation.train.currentlyBackwards) navigation.train.endpointEdges.second else navigation.train.endpointEdges.first + val firstNode = endPointEdge.first + val secondNode = endPointEdge.second + val firstEdge: TrackEdge = if(navigation.train.currentlyBackwards) + graph.getConnection(Couple.create(secondNode, firstNode)) // get the "inverted" edge when driving backwards + else + graph.getConnection(Couple.create(firstNode, secondNode)) + + val lastEdge: TrackEdge = getEdgeFromStation(navigation.destination, graph) + if(firstEdge == lastEdge){ + return Path(result,0.0,0.0) + } + + result.add(firstEdge.sendable as Edge) + result.add(lastEdge.sendable as Edge) + if(currentPath.isNotEmpty()){ + result.addAll(straightLinePathToEndNode(firstEdge, currentPath[0].first, graph)) + result.addAll(straightLinePathToEndNode(graph.getConnection(currentPath[currentPath.size - 1]), lastEdge.node1, graph)) + }else{ + result.addAll(straightLinePathToEndNode(firstEdge, lastEdge.node1, graph)) + } + + currentPath.forEachIndexed{i, obj -> + val trackEdge : TrackEdge = graph.getConnectionsFrom(obj.first).get(obj.second) ?: return@forEachIndexed + val edge = trackEdge.sendable + if(edge !is Edge) {return@forEachIndexed} + result.add(edge) + + if(i < currentPath.size - 1){ + result.addAll(straightLinePathToEndNode(trackEdge, currentPath[i + 1].first, graph)) + } + } + return Path(result,navigation.distanceStartedAt,navigation.distanceToDestination) +} diff --git a/src/main/kotlin/littlechasiu/ctm/Server.kt b/src/main/kotlin/littlechasiu/ctm/Server.kt index 2f9f42d..b24de34 100644 --- a/src/main/kotlin/littlechasiu/ctm/Server.kt +++ b/src/main/kotlin/littlechasiu/ctm/Server.kt @@ -126,6 +126,7 @@ class Server { "track-occupied" to Color(mapStyle.colors.track.occupied), "track-reserved" to Color(mapStyle.colors.track.reserved), "track-free" to Color(mapStyle.colors.track.free), + "track-path" to Color(mapStyle.colors.track.path), "signal-green" to Color(mapStyle.colors.signal.green), "signal-yellow" to Color(mapStyle.colors.signal.yellow), "signal-red" to Color(mapStyle.colors.signal.red), diff --git a/src/main/kotlin/littlechasiu/ctm/TrackMap.kt b/src/main/kotlin/littlechasiu/ctm/TrackMap.kt index e032808..9a3a7af 100644 --- a/src/main/kotlin/littlechasiu/ctm/TrackMap.kt +++ b/src/main/kotlin/littlechasiu/ctm/TrackMap.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import littlechasiu.ctm.model.Config +import littlechasiu.ctm.model.CreateTrain import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents import net.fabricmc.loader.api.FabricLoader @@ -77,6 +78,7 @@ object TrackMap { watcher.enable = config.enable server.enable = config.enable watcher.watchInterval = config.watchIntervalSeconds.seconds + CreateTrain.enableNavigationTracks = config.enableNavigationTracks server.port = config.serverPort server.mapStyle = config.mapStyle server.mapView = config.mapView diff --git a/src/main/kotlin/littlechasiu/ctm/model/Config.kt b/src/main/kotlin/littlechasiu/ctm/model/Config.kt index 45d3166..1a08066 100644 --- a/src/main/kotlin/littlechasiu/ctm/model/Config.kt +++ b/src/main/kotlin/littlechasiu/ctm/model/Config.kt @@ -27,6 +27,8 @@ data class TrackColors( val reserved: String = "pink", @EncodeDefault val free: String = "white", + @EncodeDefault + val path: String = "#2244FF", ) @Serializable @@ -140,6 +142,10 @@ data class Config @OptIn(ExperimentalSerializationApi::class) constructor( @SerialName("server_port") @EncodeDefault val serverPort: Int = 3876, + @SerialName("enable_navigation_tracks") + @EncodeDefault + val enableNavigationTracks: Boolean = true, + @SerialName("map_style") @EncodeDefault @@ -161,5 +167,6 @@ data class Config @OptIn(ExperimentalSerializationApi::class) constructor( "portals" to LayerConfig(label = "Portals"), "stations" to LayerConfig(label = "Stations"), "trains" to LayerConfig(label = "Trains"), + "trainPaths" to LayerConfig(label = "Train navigation"), ), ) diff --git a/src/main/kotlin/littlechasiu/ctm/model/Network.kt b/src/main/kotlin/littlechasiu/ctm/model/Network.kt index 7735c51..59d4973 100644 --- a/src/main/kotlin/littlechasiu/ctm/model/Network.kt +++ b/src/main/kotlin/littlechasiu/ctm/model/Network.kt @@ -30,14 +30,6 @@ data class Point( val z: Double, ) -@Serializable -data class Path( - val start: Point, - val firstControlPoint: Point, - val secondControlPoint: Point, - val end: Point, -) - @Serializable data class Edge( val dimension: String, @@ -120,6 +112,42 @@ data class TrainCar( val portal: Portal? = null, ) +@Serializable +sealed class ScheduleInstruction( + val instructionType: String, +) +@Serializable +data class ScheduleInstructionDestination( + val stationName : String, + val ticksToComplete : Int, +) : ScheduleInstruction(instructionType = "Destination") + +@Serializable +data class ScheduleInstructionThrottleChange( + val throttle : String, +) : ScheduleInstruction(instructionType = "ThrottleChange") + +@Serializable +data class ScheduleInstructionNameChange( + val newName : String, +) : ScheduleInstruction(instructionType = "NameChange") + +@Serializable +data class CreateSchedule( + val instructions: List, + val cycling: Boolean, + val paused: Boolean, + val currentEntry: Int, + val ticksInTransit: Int, +) + +@Serializable +data class Path( + val path : List, + val tripDistance : Double, + val distanceToDrive : Double +) + @Serializable data class CreateTrain( @Serializable(with = UUIDSerializer::class) @@ -129,7 +157,14 @@ data class CreateTrain( val cars: List, val backwards: Boolean, val stopped: Boolean, -) + val speed: Double, + val schedule: CreateSchedule?, + val currentPath: Path, +){ + companion object { + var enableNavigationTracks: Boolean = true + } +} @Serializable data class TrainStatus( diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/css/L.Control.Window.css b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/L.Control.Window.css new file mode 100644 index 0000000..3046a9c --- /dev/null +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/L.Control.Window.css @@ -0,0 +1,149 @@ +.leaflet-control-window-wrapper{ + display: none; + opacity: 0; + -webkit-overflow-scrolling: touch; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.nonmodal{ + z-index: 6000; +} + +.modal{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 7000; + background-color: rgba(0, 0, 0, 0.7); +} + +.visible { + display: block; + opacity: 1; +} + +.leaflet-control-window{ + position: absolute; + z-index: 2000; + border-radius: 2px; + margin: 8px; + + /** BOX SHADOW **/ + -webkit-box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.75); + -moz-box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.75); + box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.75); +} + +.control-window{ + min-width: 300px; + background-color: #ffffff; + color: #353535; + font-family: var(--ui-font); + font-weight: bold; + font-size: 15px; +} + +.control-window .destination{ + padding-left: 8px; + padding-right: 8px; + width: 100%; + border-radius: 8px; + + display: flex; + justify-content: space-between; + align-items: center; +} +.control-window .marked{ + background-color: #353535; + color: #ffffff; + width: 100%; +} + +.leaflet-control-window .titlebar{ + cursor: grab; + cursor: -webkit-grab; + cursor: -moz-grab; + padding: 10px 10px 10px 10px; +} + +.leaflet-control-window .title{ + max-height: 0px; +} + +.leaflet-control-window .close { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + border-radius: 100%; + font: 16px/14px Tahoma, Verdana, sans-serif; + cursor: pointer; + z-index:30; + + background-color: rgba(0, 0, 0, 0.40); + transition-property: background; + transition-duration: 0.2s; + transition-timing-function: linear; + + + color: #e4e4e4; + font-size: 22pt; + text-align: center; + line-height: 0.9em; +} + +.leaflet-control-window .close:hover { + background-color: rgba(0, 0, 0, 0.65); +} + +.leaflet-control-window .content{ + padding: 8px; + z-index:29; + overflow: auto; +} + +.leaflet-control-window .promptButtons{ + text-align: right; + padding: 16px; +} + +.leaflet-control-window button{ + position: relative; + display: inline-block; + background-color: transparent; + color: inherit; + + opacity: 0.5; + transition-property: opacity; + transition-duration: 0.2s; + transition-timing-function: linear; + + cursor:pointer; + font-size: medium; + font-weight: bold; + text-decoration:none; + text-align: center; + vertical-align: middle; + border: 0; + -webkit-border-radius: 4px; + border-radius: 4px; + padding: 8px; + margin: 12px 8px 0 8px; +} + +.leaflet-control-window button:focus { + outline:0; +} + +.leaflet-control-window button:hover { + opacity: 1; +} +.disabled{ + opacity: .5; + pointer-events:none; +} diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css index 21434b3..5ef7ef5 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css @@ -25,6 +25,9 @@ body { .track { stroke: var(--track-free); } +.track.path { + stroke: var(--track-path); +} .train-name, .station-name { @@ -41,6 +44,9 @@ body { background-color: #444; color: white; } +.on-schedule { + text-decoration: underline; +} .station-icon .fill { fill: var(--station-color); diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/L.Control.Window.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/L.Control.Window.js new file mode 100644 index 0000000..45b2b76 --- /dev/null +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/L.Control.Window.js @@ -0,0 +1,293 @@ +L.Control.Window = L.Control.extend({ + + includes: L.Evented.prototype || L.Mixin.Events, + + options: { + element: 'map', + className: 'control-window', + visible: false, + title: undefined, + closeButton: true, + content: undefined, + prompt: undefined, + maxWidth: 600, + modal: false, + position: 'center' + }, + initialize: function (container, options) { + var self = this; + + if (container.hasOwnProperty('options')) { container = container.getContainer(); } + + options.element = container; + L.setOptions(this, options); + + var modality = 'nonmodal'; + + if (this.options.modal){ + modality = 'modal' + } + + // Create popup window container + this._wrapper = L.DomUtil.create('div',modality+' leaflet-control-window-wrapper', L.DomUtil.get(this.options.element)); + + this._container = L.DomUtil.create('div', 'leaflet-control leaflet-control-window '+this.options.className,this._wrapper); + this._container.setAttribute('style','max-width:'+this.options.maxWidth+'px'); + + this._containerTitleBar = L.DomUtil.create('div', 'titlebar',this._container); + this.titleContent = L.DomUtil.create('h2', 'title',this._containerTitleBar); + this._containerContent = L.DomUtil.create('div', 'content' ,this._container); + this._containerPromptButtons = L.DomUtil.create('div', 'promptButtons' ,this._container); + + if (this.options.closeButton) { + this._closeButton = L.DomUtil.create('a', 'close',this._containerTitleBar); + this._closeButton.innerHTML = '×'; + } + + // Make sure we don't drag the map when we interact with the content + var stop = L.DomEvent.stopPropagation; + L.DomEvent + .on(this._wrapper, 'contextmenu', stop) + .on(this._wrapper, 'click', stop) + .on(this._wrapper, 'mousedown', stop) + .on(this._wrapper, 'touchstart', stop) + .on(this._wrapper, 'dblclick', stop) + .on(this._wrapper, 'mousewheel', stop) + .on(this._wrapper, 'MozMousePixelScroll', stop) + + // Attach event to close button + if (this.options.closeButton) { + var close = this._closeButton; + L.DomEvent.on(close, 'click', this.hide, this); + } + if (this.options.title){ + this.title(this.options.title); + } + if (this.options.content) { + this.content(this.options.content); + } + if (typeof(this.options.prompt)=='object') { + this.prompt(this.options.prompt); + } + if (this.options.visible){ + this.show(); + } + + //map.on('resize',function(){self.mapResized()}); + }, + disableBtn: function(){ + this._btnOK.disabled=true; + this._btnOK.className='disabled'; + }, + enableBtn: function(){ + this._btnOK.disabled=false; + this._btnOK.className=''; + }, + title: function(titleContent){ + if (titleContent==undefined){ + return this.options.title + } + + this.options.title = titleContent; + var title = titleContent || ''; + this.titleContent.innerHTML = title; + return this; + }, + remove: function () { + + L.DomUtil.get(this.options.element).removeChild(this._wrapper); + + // Unregister events to prevent memory leak + var stop = L.DomEvent.stopPropagation; + L.DomEvent + .off(this._wrapper, 'contextmenu', stop) + .off(this._wrapper, 'click', stop) + .off(this._wrapper, 'mousedown', stop) + .off(this._wrapper, 'touchstart', stop) + .off(this._wrapper, 'dblclick', stop) + .off(this._wrapper, 'mousewheel', stop) + .off(this._wrapper, 'MozMousePixelScroll', stop); + + // map.off('resize',self.mapResized); + + if (this._closeButton && this._close) { + var close = this._closeButton; + L.DomEvent.off(close, 'click', this.close, this); + } + return this; + }, + mapResized : function(){ + // this.show() + }, + show: function (position) { + + if (position){ + this.options.position = position + } + + L.DomUtil.addClass(this._wrapper, 'visible'); + + + this.setContentMaxHeight(); + var thisWidth = this._container.offsetWidth; + var thisHeight = this._container.offsetHeight; + var margin = 8; + + var el = L.DomUtil.get(this.options.element); + var rect = el.getBoundingClientRect(); + var width = rect.right -rect.left || Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + var height = rect.bottom -rect.top || Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + + var top = rect.top; + var left = rect.left; + var offset =0; + + // SET POSITION OF WINDOW + if (this.options.position == 'topLeft'){ + this.showOn([left,top+offset]) + } else if (this.options.position == 'left') { + this.showOn([left, top+height/2-thisHeight/2-margin+offset]) + } else if (this.options.position == 'bottomLeft') { + this.showOn([left, top+height-thisHeight-margin*2-offset]) + } else if (this.options.position == 'top') { + this.showOn([left+width/2-thisWidth/2-margin,top+offset]) + } else if (this.options.position == 'topRight') { + this.showOn([left+width-thisWidth-margin*2,top+ offset]) + } else if (this.options.position == 'right') { + this.showOn([left+width-thisWidth-margin*2, top+height/2-thisHeight/2-margin+offset]) + } else if (this.options.position == 'bottomRight') { + this.showOn([left+width-thisWidth-margin*2,top+ height-thisHeight-margin*2-offset]) + } else if (this.options.position == 'bottom') { + this.showOn([left+width/2-thisWidth/2-margin,top+ height-thisHeight-margin*2-offset]) + } else { + this.showOn([left+width/2-thisWidth/2-margin, top+top+height/2-thisHeight/2-margin+offset]) + } + + return this; + }, + showOn: function(point){ + + this.setContentMaxHeight(); + L.DomUtil.setPosition(this._container, L.point(Math.round(point[0]),Math.round(point[1]),true)); + + var draggable = new L.Draggable(this._container,this._containerTitleBar); + draggable.enable(); + + L.DomUtil.addClass(this._wrapper, 'visible'); + this.fire('show'); + return this; + }, + hide: function (e) { + + L.DomUtil.removeClass(this._wrapper, 'visible'); + this.fire('hide'); + return this; + }, + + getContainer: function () { + return this._containerContent; + }, + content: function (content) { + if (content==undefined){ + return this.options.content + } + this.options.content = content; + this.getContainer().innerHTML = content; + return this; + }, + prompt : function(promptObject){ + + if (promptObject==undefined){ + return this.options.prompt + } + + this.options.prompt = promptObject; + + this.setPromptCallback(promptObject.callback); + + this.setActionCallback(promptObject.action); + + var cancel = this.options.prompt.buttonCancel || undefined; + + var ok = this.options.prompt.buttonOK || 'OK'; + + var action = this.options.prompt.buttonAction || undefined; + + if (action != undefined) { + var btnAction = L.DomUtil.create('button','',this._containerPromptButtons); + L.DomEvent.on(btnAction, 'click',this.action, this); + btnAction.innerHTML=action; + } + + var btnOK= L.DomUtil.create('button','',this._containerPromptButtons); + L.DomEvent.on(btnOK, 'click',this.promptCallback, this); + btnOK.innerHTML=ok; + + this._btnOK=btnOK; + + if (cancel != undefined) { + var btnCancel= L.DomUtil.create('button','',this._containerPromptButtons); + L.DomEvent.on(btnCancel, 'click', this.close, this); + btnCancel.innerHTML=cancel + } + + return this; + }, + container : function(containerContent){ + if (containerContent==undefined){ + return this._container.innerHTML + } + + this._container.innerHTML = containerContent; + + if (this.options.closeButton) { + this._closeButton = L.DomUtil.create('a', 'close',this._container); + this._closeButton.innerHTML = '×'; + L.DomEvent.on(this._closeButton, 'click', this.close, this); + } + return this; + + }, + setPromptCallback : function(callback){ + var self = this; + if (typeof(callback)!= 'function') { callback = function() {console.warn('No callback function specified!');}} + var cb = function() { self.close();callback();}; + this.promptCallback = cb; + return this; + }, + setActionCallback : function(callback){ + var self = this; + if (typeof(callback)!= 'function') { callback = function() {console.warn('No callback function specified!');}} + var cb = function() { self.hide();callback();}; + this.action = cb; + return this; + }, + + setContentMaxHeight : function(){ + var margin = 68; + + if (this.options.title){ + margin += this._containerTitleBar.offsetHeight-36; + } + if (typeof(this.options.prompt) == 'object'){ + margin += this._containerPromptButtons.offsetHeight-20 + } + + var el = L.DomUtil.get(this.options.element) + var rect = el.getBoundingClientRect(); + var height = rect.bottom -rect.top; + + var maxHeight = height - margin; + this._containerContent.setAttribute('style','max-height:'+maxHeight+'px') + }, + close : function(){ + this.hide(); + this.remove(); + this.fire('close'); + return undefined; + } +}); + +L.control.window = function (container,options) { + return new L.Control.Window(container,options); +}; diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js index 8566466..d9c81be 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js @@ -7,12 +7,14 @@ let map = L.map("map", { map.createPane("tracks") map.createPane("blocks") map.createPane("signals") +map.createPane("trainPaths") map.createPane("trains") map.createPane("portals") map.createPane("stations") map.getPane("tracks").style.zIndex = 300 map.getPane("blocks").style.zIndex = 500 map.getPane("signals").style.zIndex = 600 +map.getPane("trainPaths").style.zIndex = 650 map.getPane("trains").style.zIndex = 700 map.getPane("portals").style.zIndex = 800 map.getPane("stations").style.zIndex = 800 @@ -176,9 +178,12 @@ function startMapUpdates() { }) dmgr.onTrainStatus(({ trains }) => { - lmgr.clearTrains() + //lmgr.clearTrains() + lmgr.clearTrainPaths() + tmgr.update(trains) + let whitelist = [] trains.forEach((train) => { let leadCar = null if (!train.stopped) { @@ -189,47 +194,196 @@ function startMapUpdates() { } } - train.cars.forEach((car, i) => { - let parts = car.portal - ? [ - [car.leading.dimension, [xz(car.leading.location), xz(car.portal.from.location)]], - [car.trailing.dimension, [xz(car.portal.to.location), xz(car.trailing.location)]], - ] - : [[car.leading.dimension, [xz(car.leading.location), xz(car.trailing.location)]]] - - parts.map(([dim, part]) => - L.polyline(part, { - weight: 12, - lineCap: "square", - className: "train" + (leadCar === i ? " lead-car" : ""), - pane: "trains", + if(openTrainInfos[train.id] != null){ + openTrainInfos[train.id].content(getTrainInfoHTML(train)) + + if(train.schedule != null){ + train.currentPath.path.forEach((trk) => { + const path = trk.path + if (path.length === 4) { + L.curve(["M", xz(path[0]), "C", xz(path[1]), xz(path[2]), xz(path[3])], { + className: "track path", + interactive: false, + pane: "trainPaths", + }).addTo(lmgr.layer(trk.dimension, "trainPaths")) + } else if (path.length === 2) { + L.polyline([xz(path[0]), xz(path[1])], { + className: "track path", + interactive: false, + pane: "trainPaths", + }).addTo(lmgr.layer(trk.dimension, "trainPaths")) + } }) - .bindTooltip( - train.cars.length === 1 - ? train.name - : `${train.name} ${i + 1}`, - { - className: "train-name", - direction: "right", - offset: L.point(12, 0), - opacity: 0.7, - } - ) - .addTo(lmgr.layer(dim, "trains")) - ) - - if (leadCar === i) { - let [dim, edge] = train.backwards ? parts[parts.length - 1] : parts[0] - let [head, tail] = train.backwards ? [edge[1], edge[0]] : [edge[0], edge[1]] - let angle = 180 + (Math.atan2(tail[0] - head[0], tail[1] - head[1]) * 180) / Math.PI - - L.marker(head, { - icon: headIcon, - rotationAngle: angle, - pane: "trains", - }).addTo(lmgr.layer(dim, "trains")) } + + } + train.cars.forEach((car, i) => { + if(car.leading !== undefined){ // lazily solves the missing carriage data that sometimes happen for derailed trains (ignore the problem) + let parts = car.portal + ? [ + [car.leading.dimension, [xz(car.leading.location), xz(car.portal.from.location)]], + [car.trailing.dimension, [xz(car.portal.to.location), xz(car.trailing.location)]], + ] + : [[car.leading.dimension, [xz(car.leading.location), xz(car.trailing.location)]]] + + parts.map(([dim, part]) => { + let layerGroup = lmgr.dimension(dim)["trains"] + let className = "train" + (leadCar === i ? " lead-car" : " carriage-" + i) + " " + train.id + let foundCar = false + + layerGroup.eachLayer(function(layer) { + if (layer.options.className === className) { + layer.setLatLngs(part) + whitelist.push(layer) + foundCar = true + } + }); + + if (!foundCar) { + let layer = L.polyline(part, { + weight: 12, + lineCap: "square", + className: "train" + (leadCar === i ? " lead-car" : " carriage-" + i) + " " + train.id, + pane: "trains", + }).addEventListener("click",function(event){ + if(!openTrainInfos[train.id]) { + openTrainInfo(train, dim) + } + },true).bindTooltip( + (train.cars.length === 1 + ? train.name + : `${train.name} ${i + 1}`), + { + className: "train-name", + direction: "right", + offset: L.point(12, 0), + opacity: 0.7, + } + ) + .addTo(lmgr.layer(dim, "trains")) + whitelist.push(layer) + } + }) + + if (leadCar === i) { + let [dim, edge] = train.backwards ? parts[parts.length - 1] : parts[0] + let [head, tail] = train.backwards ? [edge[1], edge[0]] : [edge[0], edge[1]] + let angle = 180 + (Math.atan2(tail[0] - head[0], tail[1] - head[1]) * 180) / Math.PI + + let layer = L.marker(head, { + icon: headIcon, + className: "train-head", + rotationAngle: angle, + pane: "trains", + }).addTo(lmgr.layer(dim,"trains")) + whitelist.push(layer) + } + } + }) + }) + Array.from(Object.values(lmgr.actualLayers)).forEach((obj) => { + obj.trains.eachLayer(function(layer) { + if (!whitelist.includes(layer)) { + obj.trains.removeLayer(layer) + } + }); }) }) +} + +function getTrainInfoHTML(train){ + let htmlData = "
" + htmlData += "Speed: " + Math.abs(train.speed * 20).toFixed(1) + " Blocks/s
" + if(train.stopped){ + htmlData += "Status: Stopped
" + }else{ + htmlData += "Status: Moving
" + } + if(train.schedule){ + htmlData += "Mode: Schedule" + }else{ + htmlData += "Mode: Manual" + return htmlData + } + htmlData += "
" + if(train.schedule) { + let currentInstruction = train.schedule.currentEntry + let instructions = train.schedule.instructions + htmlData += "On schedule
" + if(instructions[currentInstruction].instructionType === "Destination") { + htmlData += "Next destination: " + instructions[currentInstruction].stationName + "" + }else{ + htmlData += "Next destination: Unknown" + } + htmlData += "
" + + htmlData += "Time until arrival: " + ticksToMMSS(calculateRemainingTicks(train, train.schedule.currentEntry)) + "
" + + + if(train.currentPath.tripDistance === 0){ + htmlData += "Distance: Arrived" + }else { + htmlData += "Distance: " + Math.floor(train.currentPath.distanceToDrive) + "/" + Math.floor(train.currentPath.tripDistance) + " blocks" + } + htmlData += "
" + + train.schedule.instructions.forEach((instruction, i) => { + if (instruction.instructionType === "Destination") { + let className = "destination" + if (i === train.schedule.currentEntry) { + className += " marked" + } + htmlData += "
" + htmlData += "" + instruction.stationName + "" + htmlData += "" + ticksToMMSS(calculateRemainingTicks(train, i)) + htmlData += "
"; + } + }) + + } + htmlData += "" + return htmlData +} + +let openTrainInfos = {} +function openTrainInfo(train){ + var win = L.control.window(map,{title:train.name,content:getTrainInfoHTML(train)}).showOn([100,100]) + win._closeButton.addEventListener("click", function(event){ + delete openTrainInfos[train.id] }) + + openTrainInfos[train.id] = win +} + +function calculateRemainingTicks(train, instructionIndex){ + let instructions = train.schedule.instructions + let currentIndex = train.schedule.currentEntry + + let totalTicks = 0 + for (let i = 0; i < instructions.length; i++) { + let index = (currentIndex + i)%instructions.length + if(instructions[index].instructionType === "Destination"){ + if(instructions[index].ticksToComplete === -1){ + return "Unknown" + } + totalTicks += instructions[index].ticksToComplete + } + if(index === instructionIndex){ + break + } + } + return totalTicks - train.schedule.ticksInTransit +} + +function ticksToMMSS(ticks) { + if(ticks === "Unknown"){ + return "Unknown" + } + if(ticks < 0){ + ticks = 0 + } + let seconds = Math.floor(ticks / 20) + let minutes = Math.floor(seconds / 60); + let remainingSeconds = seconds % 60; + return (minutes < 10 ? '0' : '') + minutes + ':' + (remainingSeconds < 10 ? '0' : '') + remainingSeconds; } diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.control.list.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.control.list.js index 05e7c57..d43bf09 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.control.list.js +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.control.list.js @@ -78,6 +78,9 @@ L.Control.List = L.Control.extend({ el.addEventListener("click", (e) => { let [dimension, x, _, z] = e.target.dataset.coords.split(";") this.options.layerManager.switchToDimension(dimension) + if(this.options.itemClassName === "train" && !openTrainInfos[info.id]){ + openTrainInfo(info) + } this._map.panTo([parseFloat(z), parseFloat(x)]) }) @@ -91,7 +94,7 @@ L.Control.List = L.Control.extend({ let el = Array.from(this._list.children).filter((e) => e.dataset.id === id)[0] if (!!el) { - el.textContent = info.name + //el.textContent = info.name el.dataset.coords = this.options.coordsFunction(info).join(";") } }, @@ -146,8 +149,12 @@ L.control.trainList = (layerManager) => itemClassName: "train", tooltip: "Trains", coordsFunction: (t) => { - const c = t.cars[0].leading - return [c.dimension, c.location.x, c.location.y, c.location.z] + if(t.cars[0].leading) { + const c = t.cars[0].leading + return [c.dimension, c.location.x, c.location.y, c.location.z] + }else{ + return ["minecraft:overworld",0,0,0] + } }, layerManager, }) diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.layer-manager.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.layer-manager.js index f46853f..0942d62 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.layer-manager.js +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.layer-manager.js @@ -13,6 +13,7 @@ class LayerManager { portals: L.layerGroup([]).addTo(map), stations: L.layerGroup([]).addTo(map), trains: L.layerGroup([]).addTo(map), + trainPaths: L.layerGroup([]).addTo(map), } this.actualLayers = {} @@ -55,6 +56,7 @@ class LayerManager { portals: L.layerGroup([]), stations: L.layerGroup([]), trains: L.layerGroup([]), + trainPaths: L.layerGroup([]), } let layer = (this.dimensionLayers[name] = L.layerGroup([])) layer.name = name @@ -134,7 +136,10 @@ class LayerManager { _clearLayers(key) { Array.from(Object.values(this.actualLayers)).forEach((obj) => obj[key].clearLayers()) } - + + clearTrainPaths() { + this._clearLayers("trainPaths") + } clearTracks() { this._clearLayers("tracks") } diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.train-manager.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.train-manager.js index 13708b0..5dddd05 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.train-manager.js +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/ctm.train-manager.js @@ -1,30 +1,46 @@ class TrainManager { constructor(map, layerManager) { - this.trains = new Set() + this.trains = new Map() this.map = map this.control = L.control.trainList(layerManager).addTo(map) } update(trains) { - const thisTrains = new Set() + const thisTrains = new Map() + let changed = false trains.forEach((t) => { - thisTrains.add(t.id) + thisTrains.set(t.id, t) if (this.trains.has(t.id)) { - this.control.update(t.id, t) + if(distance(this.trains.get(t.id).cars[0].leading.location, t.cars[0].leading.location) > 10){ + this.trains.set(t.id, t) + this.control.update(t.id, t) + } } else { - this.trains.add(t.id) + this.trains.set(t.id, t) this.control.add(t.id, t) + changed = true } }) - this.trains.forEach((t) => { - if (!thisTrains.has(t)) { - this.trains.delete(t) - this.control.remove(t.id) + this.trains.forEach((train, id) => { + if (!thisTrains.has(id)) { + this.trains.delete(id) + this.control.remove(id) + changed = true } }) - this.control.reorder() + if(changed) { + this.control.reorder() + } } } + +function distance(vector1, vector2) { + const dx = vector1.x - vector2.x; + const dy = vector1.y - vector2.y; + const dz = vector1.z - vector2.z; + + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} \ No newline at end of file diff --git a/src/main/resources/assets/littlechasiu/ctm/static/index.html b/src/main/resources/assets/littlechasiu/ctm/static/index.html index 5a92a28..a445376 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/index.html +++ b/src/main/resources/assets/littlechasiu/ctm/static/index.html @@ -5,6 +5,7 @@ + @@ -16,6 +17,7 @@ +