mirror of
https://github.com/corda/corda.git
synced 2024-12-18 20:47:57 +00:00
Refactor network simulator towards MVC model
* Reordered functions in the network map visualiser as a part of an ongoing refactor. * Started separating concerns out of the NetworkMapVisualiser. * Moved more view logic to the view class. * Split out some of the progress tracker visual logic out into the view. * Finished partial refactor to push model data into the model class. * Moved some more view and model logic from controller to model.
This commit is contained in:
parent
ba22990938
commit
f76c7c9cf9
@ -22,16 +22,9 @@ import javafx.application.Application
|
||||
import javafx.application.Platform
|
||||
import javafx.beans.property.SimpleDoubleProperty
|
||||
import javafx.beans.value.WritableValue
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.event.EventHandler
|
||||
import javafx.geometry.Insets
|
||||
import javafx.geometry.Pos
|
||||
import javafx.scene.Group
|
||||
import javafx.scene.Node
|
||||
import javafx.scene.Scene
|
||||
import javafx.scene.control.*
|
||||
import javafx.scene.image.Image
|
||||
import javafx.scene.image.ImageView
|
||||
import javafx.scene.input.KeyCode
|
||||
import javafx.scene.input.KeyCodeCombination
|
||||
import javafx.scene.layout.*
|
||||
@ -39,7 +32,6 @@ import javafx.scene.paint.Color
|
||||
import javafx.scene.shape.Circle
|
||||
import javafx.scene.shape.Line
|
||||
import javafx.scene.shape.Polygon
|
||||
import javafx.scene.text.Font
|
||||
import javafx.stage.Stage
|
||||
import javafx.util.Duration
|
||||
import rx.Scheduler
|
||||
@ -52,24 +44,20 @@ import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.concurrent.scheduleAtFixedRate
|
||||
import kotlin.system.exitProcess
|
||||
import com.r3cev.corda.netmap.VisualiserModel.Style
|
||||
|
||||
fun <T : Any> WritableValue<T>.keyValue(endValue: T, interpolator: Interpolator = Interpolator.EASE_OUT) = KeyValue(this, endValue, interpolator)
|
||||
|
||||
// TODO: This code is all horribly ugly. Refactor to use TornadoFX to clean it up.
|
||||
|
||||
class NetworkMapVisualiser : Application() {
|
||||
//========================
|
||||
// Enums
|
||||
//========================
|
||||
enum class NodeType {
|
||||
BANK, SERVICE
|
||||
}
|
||||
|
||||
enum class Style {
|
||||
MAP, CIRCLE;
|
||||
|
||||
override fun toString(): String {
|
||||
return name.toLowerCase().capitalize()
|
||||
}
|
||||
}
|
||||
|
||||
enum class RunPauseButtonLabel {
|
||||
RUN, PAUSE;
|
||||
|
||||
@ -78,6 +66,9 @@ class NetworkMapVisualiser : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
//========================
|
||||
// Classes
|
||||
//========================
|
||||
sealed class RunningPausedState {
|
||||
class Running(val tickTimer: TimerTask): RunningPausedState()
|
||||
class Paused(): RunningPausedState()
|
||||
@ -91,39 +82,32 @@ class NetworkMapVisualiser : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
val stageWidth = 1024.0
|
||||
val stageHeight = 768.0
|
||||
var defaultZoom = 0.7
|
||||
//========================
|
||||
// Object variables
|
||||
//========================
|
||||
private val view = VisualiserView()
|
||||
private val model = VisualiserModel()
|
||||
|
||||
private lateinit var stage: Stage
|
||||
private lateinit var root: Pane
|
||||
|
||||
val bitmapWidth = 1900.0
|
||||
val bitmapHeight = 1900.0
|
||||
var stepDuration = Duration.millis(500.0)
|
||||
val timer = Timer()
|
||||
val uiThread: Scheduler = Schedulers.from { Platform.runLater(it) }
|
||||
|
||||
//========================
|
||||
// Init and shutdown
|
||||
//========================
|
||||
init {
|
||||
BriefLogFormatter.initVerbose(InMemoryMessagingNetwork.MESSAGES_LOG_NAME)
|
||||
}
|
||||
|
||||
var simulation = IRSSimulation(true, false, null) // Manually pumped.
|
||||
|
||||
val timer = Timer()
|
||||
|
||||
val uiThread: Scheduler = Schedulers.from { Platform.runLater(it) }
|
||||
|
||||
var displayStyle: Style = Style.MAP
|
||||
var bankCount: Int = 0
|
||||
var serviceCount: Int = 0
|
||||
|
||||
override fun start(stage: Stage) {
|
||||
this.stage = stage
|
||||
model.view = view
|
||||
model.presentationMode = "--presentation-mode" in parameters.raw
|
||||
buildScene(stage)
|
||||
model.displayStyle = if ("--circle" in parameters.raw) { Style.CIRCLE } else { model.displayStyle }
|
||||
|
||||
// Update the white-backgrounded label indicating what protocol step it's up to.
|
||||
simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair<Simulation.SimulatedNode, ProgressTracker.Change> ->
|
||||
model.simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair<Simulation.SimulatedNode, ProgressTracker.Change> ->
|
||||
val (node, change) = step
|
||||
val label = nodesToWidgets[node]!!.statusLabel
|
||||
val label = model.nodesToWidgets[node]!!.statusLabel
|
||||
if (change is ProgressTracker.Change.Position) {
|
||||
// Fade in the status label if it's our first step.
|
||||
if (label.text == "") {
|
||||
@ -150,23 +134,20 @@ class NetworkMapVisualiser : Application() {
|
||||
}
|
||||
}
|
||||
// Fire the message bullets between nodes.
|
||||
simulation.network.messagingNetwork.sentMessages.observeOn(uiThread).subscribe { msg: InMemoryMessagingNetwork.MessageTransfer ->
|
||||
val senderNode: MockNetwork.MockNode = simulation.network.addressToNode(msg.sender.myAddress)
|
||||
val destNode: MockNetwork.MockNode = simulation.network.addressToNode(msg.recipients as SingleMessageRecipient)
|
||||
model.simulation.network.messagingNetwork.sentMessages.observeOn(uiThread).subscribe { msg: InMemoryMessagingNetwork.MessageTransfer ->
|
||||
val senderNode: MockNetwork.MockNode = model.simulation.network.addressToNode(msg.sender.myAddress)
|
||||
val destNode: MockNetwork.MockNode = model.simulation.network.addressToNode(msg.recipients as SingleMessageRecipient)
|
||||
|
||||
if (transferIsInteresting(msg)) {
|
||||
nodesToWidgets[senderNode]!!.pulseAnim.play()
|
||||
fireBulletBetweenNodes(senderNode, destNode, "bank", "bank")
|
||||
model.nodesToWidgets[senderNode]!!.pulseAnim.play()
|
||||
model.fireBulletBetweenNodes(senderNode, destNode, "bank", "bank")
|
||||
}
|
||||
}
|
||||
// Pulse all parties in a trade when the trade completes
|
||||
simulation.doneSteps.observeOn(uiThread).subscribe { nodes: Collection<Simulation.SimulatedNode> ->
|
||||
nodes.forEach { nodesToWidgets[it]!!.longPulseAnim.play() }
|
||||
model.simulation.doneSteps.observeOn(uiThread).subscribe { nodes: Collection<Simulation.SimulatedNode> ->
|
||||
nodes.forEach { model.nodesToWidgets[it]!!.longPulseAnim.play() }
|
||||
}
|
||||
|
||||
if ("--circle" in parameters.raw)
|
||||
updateDisplayStyle(Style.CIRCLE)
|
||||
|
||||
stage.setOnCloseRequest { exitProcess(0) }
|
||||
//stage.isMaximized = true
|
||||
stage.show()
|
||||
@ -188,59 +169,15 @@ class NetworkMapVisualiser : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
// -23.2031,29.8406,33.0469,64.3209
|
||||
private val mapImage = ImageView(Image(NetworkMapVisualiser::class.java.getResourceAsStream("Europe.jpg")))
|
||||
private var scrollPane: ScrollPane? = null
|
||||
private lateinit var splitter: SplitPane
|
||||
|
||||
//========================
|
||||
// View functions
|
||||
//========================
|
||||
private fun buildScene(stage: Stage) {
|
||||
NetworkMapVisualiser::class.java.getResourceAsStream("SourceSansPro-Regular.otf").use {
|
||||
Font.loadFont(it, 120.0)
|
||||
}
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
mapImage.fitWidth = bitmapWidth * defaultZoom
|
||||
mapImage.fitHeight = bitmapHeight * defaultZoom
|
||||
mapImage.onZoom = EventHandler<javafx.scene.input.ZoomEvent> { event ->
|
||||
event.consume()
|
||||
mapImage.fitWidth = mapImage.fitWidth * event.zoomFactor
|
||||
mapImage.fitHeight = mapImage.fitHeight * event.zoomFactor
|
||||
repositionNodes()
|
||||
}
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
val scaleRatio = Math.min(stageWidth / bitmapWidth, stageHeight / bitmapHeight)
|
||||
mapImage.fitWidth = bitmapWidth * scaleRatio
|
||||
mapImage.fitHeight = bitmapHeight * scaleRatio
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundColor: Color = mapImage.image.pixelReader.getColor(0, 0)
|
||||
root = Pane(mapImage)
|
||||
root.background = Background(BackgroundFill(backgroundColor, CornerRadii.EMPTY, Insets.EMPTY))
|
||||
createNodes()
|
||||
|
||||
scrollPane = buildScrollPane(backgroundColor)
|
||||
|
||||
val vbox = makeTopBar()
|
||||
|
||||
StackPane.setAlignment(vbox, Pos.TOP_CENTER)
|
||||
|
||||
// Now build the sidebar
|
||||
val defaultSplitterPosition = 0.3
|
||||
splitter = SplitPane(buildSidebar(), scrollPane)
|
||||
splitter.styleClass += "splitter"
|
||||
Platform.runLater {
|
||||
splitter.dividers[0].position = defaultSplitterPosition
|
||||
}
|
||||
VBox.setVgrow(splitter, Priority.ALWAYS)
|
||||
|
||||
// And the left hide button.
|
||||
val hideButton = makeHideButton(defaultSplitterPosition)
|
||||
|
||||
val screenStack = VBox(vbox, StackPane(splitter, hideButton))
|
||||
screenStack.styleClass += "root-pane"
|
||||
stage.scene = Scene(screenStack, backgroundColor)
|
||||
view.stage = stage
|
||||
view.setup(model.runningPausedState, model.displayStyle, model.presentationMode)
|
||||
bindSidebar()
|
||||
bindTopbar()
|
||||
model.createNodes()
|
||||
|
||||
// Spacebar advances simulation by one step.
|
||||
stage.scene.accelerators[KeyCodeCombination(KeyCode.SPACE)] = Runnable { onNextInvoked() }
|
||||
@ -248,178 +185,172 @@ class NetworkMapVisualiser : Application() {
|
||||
reloadStylesheet(stage)
|
||||
|
||||
stage.focusedProperty().addListener { value, old, new ->
|
||||
if (new)
|
||||
if (new) {
|
||||
reloadStylesheet(stage)
|
||||
}
|
||||
|
||||
stage.width = 1024.0
|
||||
stage.height = 768.0
|
||||
}
|
||||
|
||||
private val hideButton = Button("«").apply { styleClass += "hide-sidebar-button" }
|
||||
fun bindHideButtonPosition() {
|
||||
hideButton.translateXProperty().unbind()
|
||||
hideButton.translateXProperty().bind(splitter.dividers[0].positionProperty().multiply(splitter.widthProperty()).subtract(hideButton.widthProperty()))
|
||||
}
|
||||
|
||||
private fun makeHideButton(defaultSplitterPosition: Double): Button {
|
||||
var hideButtonToggled = false
|
||||
hideButton.isFocusTraversable = false
|
||||
hideButton.setOnAction {
|
||||
if (!hideButtonToggled) {
|
||||
hideButton.translateXProperty().unbind()
|
||||
Timeline(
|
||||
KeyFrame(Duration.millis(500.0),
|
||||
splitter.dividers[0].positionProperty().keyValue(0.0),
|
||||
hideButton.translateXProperty().keyValue(0.0),
|
||||
hideButton.rotateProperty().keyValue(180.0)
|
||||
)
|
||||
).play()
|
||||
} else {
|
||||
bindHideButtonPosition()
|
||||
Timeline(
|
||||
KeyFrame(Duration.millis(500.0),
|
||||
splitter.dividers[0].positionProperty().keyValue(defaultSplitterPosition),
|
||||
hideButton.rotateProperty().keyValue(0.0)
|
||||
)
|
||||
).play()
|
||||
}
|
||||
hideButtonToggled = !hideButtonToggled
|
||||
}
|
||||
bindHideButtonPosition()
|
||||
StackPane.setAlignment(hideButton, Pos.TOP_LEFT)
|
||||
return hideButton
|
||||
}
|
||||
|
||||
var started = false
|
||||
private fun startSimulation() {
|
||||
if (!started) {
|
||||
simulation.start()
|
||||
started = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeTopBar(): VBox {
|
||||
val nextButton = Button("Next").apply {
|
||||
styleClass += "button"
|
||||
styleClass += "next-button"
|
||||
}
|
||||
|
||||
var runningPausedState: RunningPausedState = RunningPausedState.Paused()
|
||||
val runPauseButton = Button(runningPausedState.buttonLabel.toString()).apply {
|
||||
styleClass += "button"
|
||||
styleClass += "run-button"
|
||||
}
|
||||
|
||||
val simulateInitialisationCheckbox = CheckBox("Simulate initialisation")
|
||||
|
||||
val resetButton = Button("Reset").apply {
|
||||
setOnAction {
|
||||
reset()
|
||||
}
|
||||
styleClass += "button"
|
||||
styleClass += "reset-button"
|
||||
}
|
||||
|
||||
nextButton.setOnAction {
|
||||
if (!simulateInitialisationCheckbox.isSelected && !simulation.networkInitialisationFinished.isDone) {
|
||||
private fun bindTopbar() {
|
||||
view.resetButton.setOnAction({reset()})
|
||||
view.nextButton.setOnAction {
|
||||
if (!view.simulateInitialisationCheckbox.isSelected && !model.simulation.networkInitialisationFinished.isDone) {
|
||||
skipNetworkInitialisation()
|
||||
} else {
|
||||
onNextInvoked()
|
||||
}
|
||||
}
|
||||
|
||||
simulation.networkInitialisationFinished.then {
|
||||
simulateInitialisationCheckbox.isVisible = false
|
||||
model.simulation.networkInitialisationFinished.then {
|
||||
view.simulateInitialisationCheckbox.isVisible = false
|
||||
}
|
||||
|
||||
runPauseButton.setOnAction {
|
||||
val oldRunningPausedState = runningPausedState
|
||||
view.runPauseButton.setOnAction {
|
||||
val oldRunningPausedState = model.runningPausedState
|
||||
val newRunningPausedState = when (oldRunningPausedState) {
|
||||
is RunningPausedState.Running -> {
|
||||
is NetworkMapVisualiser.RunningPausedState.Running -> {
|
||||
oldRunningPausedState.tickTimer.cancel()
|
||||
|
||||
nextButton.isDisable = false
|
||||
resetButton.isDisable = false
|
||||
view.nextButton.isDisable = false
|
||||
view.resetButton.isDisable = false
|
||||
|
||||
RunningPausedState.Paused()
|
||||
NetworkMapVisualiser.RunningPausedState.Paused()
|
||||
}
|
||||
is RunningPausedState.Paused -> {
|
||||
val tickTimer = timer.scheduleAtFixedRate(stepDuration.toMillis().toLong(), stepDuration.toMillis().toLong()) {
|
||||
is NetworkMapVisualiser.RunningPausedState.Paused -> {
|
||||
val tickTimer = timer.scheduleAtFixedRate(model.stepDuration.toMillis().toLong(), model.stepDuration.toMillis().toLong()) {
|
||||
Platform.runLater {
|
||||
onNextInvoked()
|
||||
}
|
||||
}
|
||||
|
||||
nextButton.isDisable = true
|
||||
resetButton.isDisable = true
|
||||
view.nextButton.isDisable = true
|
||||
view.resetButton.isDisable = true
|
||||
|
||||
if (!simulateInitialisationCheckbox.isSelected && !simulation.networkInitialisationFinished.isDone) {
|
||||
if (!view.simulateInitialisationCheckbox.isSelected && !model.simulation.networkInitialisationFinished.isDone) {
|
||||
skipNetworkInitialisation()
|
||||
}
|
||||
|
||||
RunningPausedState.Running(tickTimer)
|
||||
NetworkMapVisualiser.RunningPausedState.Running(tickTimer)
|
||||
}
|
||||
}
|
||||
|
||||
runPauseButton.text = newRunningPausedState.buttonLabel.toString()
|
||||
runningPausedState = newRunningPausedState
|
||||
view.runPauseButton.text = newRunningPausedState.buttonLabel.toString()
|
||||
model.runningPausedState = newRunningPausedState
|
||||
}
|
||||
view.styleChoice.selectionModel.selectedItemProperty()
|
||||
.addListener { ov, value, newValue -> model.displayStyle = newValue }
|
||||
model.simulation.dateChanges.observeOn(uiThread).subscribe { view.dateLabel.text = it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) }
|
||||
}
|
||||
|
||||
private fun reloadStylesheet(stage: Stage) {
|
||||
stage.scene.stylesheets.clear()
|
||||
|
||||
// Enable hot reload without needing to rebuild.
|
||||
val mikesCSS = "/Users/mike/Source/R3/r3dlg-prototyping/network-explorer/src/main/resources/com/r3cev/corda/netmap/styles.css"
|
||||
if (Files.exists(Paths.get(mikesCSS)))
|
||||
stage.scene.stylesheets.add("file://$mikesCSS")
|
||||
else
|
||||
stage.scene.stylesheets.add(NetworkMapVisualiser::class.java.getResource("styles.css").toString())
|
||||
}
|
||||
|
||||
//========================
|
||||
// Temp
|
||||
//========================
|
||||
private fun bindSidebar() {
|
||||
model.simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair<Simulation.SimulatedNode, ProgressTracker.Change> ->
|
||||
val (node, change) = step
|
||||
|
||||
if (change is ProgressTracker.Change.Position) {
|
||||
val tracker = change.tracker.topLevelTracker
|
||||
if (change.newStep == ProgressTracker.DONE) {
|
||||
if (change.tracker == tracker) {
|
||||
// Protocol done; schedule it for removal in a few seconds. We batch them up to make nicer
|
||||
// animations.
|
||||
println("Protocol done for ${node.info.identity.name}")
|
||||
model.doneTrackers += tracker
|
||||
} else {
|
||||
// Subprotocol is done; ignore it.
|
||||
}
|
||||
} else if (!model.trackerBoxes.containsKey(tracker)) {
|
||||
// New protocol started up; add.
|
||||
val extraLabel = model.simulation.extraNodeLabels[node]
|
||||
val label = if (extraLabel != null) "${node.storage.myLegalIdentity.name}: $extraLabel" else node.storage.myLegalIdentity.name
|
||||
val widget = view.buildProgressTrackerWidget(label, tracker.topLevelTracker)
|
||||
bindProgressTracketWidget(tracker.topLevelTracker, widget)
|
||||
println("Added: ${tracker}, ${widget}")
|
||||
model.trackerBoxes[tracker] = widget.vbox
|
||||
view.sidebar.children += widget.vbox
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val displayStyles = FXCollections.observableArrayList<Style>()
|
||||
Style.values().forEach { displayStyles.add(it) }
|
||||
|
||||
val styleChoice = ChoiceBox(displayStyles).apply {
|
||||
styleClass += "choice"
|
||||
styleClass += "style-choice"
|
||||
Timer().scheduleAtFixedRate(0, 500) {
|
||||
Platform.runLater {
|
||||
for (tracker in model.doneTrackers) {
|
||||
val pane = model.trackerBoxes[tracker]!!
|
||||
// Slide the other tracker widgets up and over this one.
|
||||
val slideProp = SimpleDoubleProperty(0.0)
|
||||
slideProp.addListener { obv -> pane.padding = Insets(0.0, 0.0, slideProp.value, 0.0) }
|
||||
val timeline = Timeline(
|
||||
KeyFrame(Duration(250.0),
|
||||
KeyValue(pane.opacityProperty(), 0.0),
|
||||
KeyValue(slideProp, -pane.height - 50.0) // Subtract the bottom padding gap.
|
||||
)
|
||||
)
|
||||
timeline.setOnFinished {
|
||||
println("Removed: ${tracker}")
|
||||
val vbox = model.trackerBoxes.remove(tracker)
|
||||
view.sidebar.children.remove(vbox)
|
||||
}
|
||||
timeline.play()
|
||||
}
|
||||
model.doneTrackers.clear()
|
||||
}
|
||||
}
|
||||
styleChoice.value = displayStyle
|
||||
styleChoice.selectionModel.selectedItemProperty()
|
||||
.addListener { ov, value, newValue -> updateDisplayStyle(newValue) }
|
||||
}
|
||||
|
||||
val dropShadow = Pane().apply { styleClass += "drop-shadow-pane-horizontal"; minHeight = 8.0 }
|
||||
val logoImage = ImageView(javaClass.getResource("R3 logo.png").toExternalForm())
|
||||
logoImage.fitHeight = 65.0
|
||||
logoImage.isPreserveRatio = true
|
||||
val logoLabel = HBox(logoImage, VBox(
|
||||
Label("D I S T R I B U T E D L E D G E R G R O U P").apply { styleClass += "dlg-label" },
|
||||
Label("Network Simulator").apply { styleClass += "logo-label" }
|
||||
))
|
||||
logoLabel.spacing = 10.0
|
||||
HBox.setHgrow(logoLabel, Priority.ALWAYS)
|
||||
logoLabel.setPrefSize(Region.USE_COMPUTED_SIZE, Region.USE_PREF_SIZE)
|
||||
val dateLabel = Label("").apply { styleClass += "date-label" }
|
||||
simulation.dateChanges.observeOn(uiThread).subscribe { dateLabel.text = it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) }
|
||||
|
||||
// Buttons area. In presentation mode there are no controls visible and you must use the keyboard.
|
||||
val hbox = if ("--presentation-mode" in parameters.raw) {
|
||||
HBox(logoLabel, dateLabel).apply { styleClass += "controls-hbox" }
|
||||
} else {
|
||||
HBox(logoLabel, dateLabel, simulateInitialisationCheckbox, runPauseButton, nextButton, resetButton, styleChoice).apply { styleClass += "controls-hbox" }
|
||||
private fun bindProgressTracketWidget(tracker: ProgressTracker, widget: TrackerWidget) {
|
||||
val allSteps: List<Pair<Int, ProgressTracker.Step>> = tracker.allSteps
|
||||
tracker.changes.observeOn(uiThread).subscribe { step: ProgressTracker.Change ->
|
||||
val stepHeight = widget.cursorBox.height / allSteps.size
|
||||
if (step is ProgressTracker.Change.Position) {
|
||||
// Figure out the index of the new step.
|
||||
val curStep = allSteps.indexOfFirst { it.second == step.newStep }
|
||||
// Animate the cursor to the right place.
|
||||
with(TranslateTransition(Duration(350.0), widget.cursor)) {
|
||||
fromY = widget.cursor.translateY
|
||||
toY = (curStep * stepHeight) + 22.5
|
||||
play()
|
||||
}
|
||||
} else if (step is ProgressTracker.Change.Structural) {
|
||||
val new = view.buildProgressTrackerWidget(widget.label.text, tracker)
|
||||
val prevWidget = model.trackerBoxes[step.tracker] ?: throw AssertionError("No previous widget for tracker: ${step.tracker}")
|
||||
val i = (prevWidget.parent as VBox).children.indexOf(model.trackerBoxes[step.tracker])
|
||||
(prevWidget.parent as VBox).children[i] = new.vbox
|
||||
model.trackerBoxes[step.tracker] = new.vbox
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//========================
|
||||
// Controller functions
|
||||
//========================
|
||||
var started = false
|
||||
private fun startSimulation() {
|
||||
if (!started) {
|
||||
model.simulation.start()
|
||||
started = true
|
||||
}
|
||||
hbox.styleClass += "fat-buttons"
|
||||
hbox.spacing = 20.0
|
||||
hbox.alignment = Pos.CENTER_RIGHT
|
||||
hbox.padding = Insets(10.0, 20.0, 10.0, 20.0)
|
||||
val vbox = VBox(hbox, dropShadow)
|
||||
vbox.styleClass += "controls-vbox"
|
||||
vbox.setPrefSize(Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE)
|
||||
vbox.setMaxSize(Region.USE_COMPUTED_SIZE, Region.USE_PREF_SIZE)
|
||||
return vbox
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
simulation.stop()
|
||||
simulation = IRSSimulation(true, false, null)
|
||||
model.simulation.stop()
|
||||
model.simulation = IRSSimulation(true, false, null)
|
||||
started = false
|
||||
start(this.stage)
|
||||
start(view.stage)
|
||||
}
|
||||
|
||||
private fun skipNetworkInitialisation() {
|
||||
startSimulation()
|
||||
while (!simulation.networkInitialisationFinished.isDone) {
|
||||
while (!model.simulation.networkInitialisationFinished.isDone) {
|
||||
iterateSimulation()
|
||||
}
|
||||
}
|
||||
@ -435,7 +366,7 @@ class NetworkMapVisualiser : Application() {
|
||||
private fun iterateSimulation() {
|
||||
// Loop until either we ran out of things to do, or we sent an interesting message.
|
||||
while (true) {
|
||||
val transfer: InMemoryMessagingNetwork.MessageTransfer = simulation.iterate() ?: break
|
||||
val transfer: InMemoryMessagingNetwork.MessageTransfer = model.simulation.iterate() ?: break
|
||||
if (transferIsInteresting(transfer))
|
||||
break
|
||||
else
|
||||
@ -451,384 +382,6 @@ class NetworkMapVisualiser : Application() {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildScrollPane(backgroundColor: Color): ScrollPane {
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
mapImage.fitWidth = bitmapWidth * defaultZoom
|
||||
mapImage.fitHeight = bitmapHeight * defaultZoom
|
||||
mapImage.onZoom = EventHandler<javafx.scene.input.ZoomEvent> { event ->
|
||||
event.consume()
|
||||
mapImage.fitWidth = mapImage.fitWidth * event.zoomFactor
|
||||
mapImage.fitHeight = mapImage.fitHeight * event.zoomFactor
|
||||
repositionNodes()
|
||||
}
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
val scaleRatio = Math.min(stageWidth / bitmapWidth, stageHeight / bitmapHeight)
|
||||
mapImage.fitWidth = bitmapWidth * scaleRatio
|
||||
mapImage.fitHeight = bitmapHeight * scaleRatio
|
||||
}
|
||||
}
|
||||
return ScrollPane(Group(root)).apply {
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
hvalue = 0.4
|
||||
vvalue = 0.7
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
hvalue = 0.0
|
||||
vvalue = 0.0
|
||||
}
|
||||
}
|
||||
hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
vbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
isPannable = true
|
||||
isFocusTraversable = false
|
||||
style = "-fx-background-color: " + colorToRgb(backgroundColor)
|
||||
styleClass += "edge-to-edge"
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorToRgb(color: Color): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
builder.append("rgb(")
|
||||
builder.append(Math.round(color.red * 256))
|
||||
builder.append(",")
|
||||
builder.append(Math.round(color.green * 256))
|
||||
builder.append(",")
|
||||
builder.append(Math.round(color.blue * 256))
|
||||
builder.append(")")
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun repositionNodes() {
|
||||
for ((index, bank) in simulation.banks.withIndex()) {
|
||||
nodesToWidgets[bank]!!.position(index, when (displayStyle) {
|
||||
Style.MAP -> { node, index -> nodeMapCoords(node) }
|
||||
Style.CIRCLE -> { node, index -> nodeCircleCoords(NodeType.BANK, index) }
|
||||
})
|
||||
}
|
||||
for ((index, serviceProvider) in (simulation.serviceProviders + simulation.regulators).withIndex()) {
|
||||
nodesToWidgets[serviceProvider]!!.position(index, when (displayStyle) {
|
||||
Style.MAP -> { node, index -> nodeMapCoords(node) }
|
||||
Style.CIRCLE -> { node, index -> nodeCircleCoords(NodeType.SERVICE, index) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private val trackerBoxes = HashMap<ProgressTracker, Pane>()
|
||||
private val doneTrackers = ArrayList<ProgressTracker>()
|
||||
private fun buildSidebar(): Node {
|
||||
val sidebar = VBox()
|
||||
sidebar.styleClass += "sidebar"
|
||||
sidebar.isFillWidth = true
|
||||
simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair<Simulation.SimulatedNode, ProgressTracker.Change> ->
|
||||
val (node, change) = step
|
||||
|
||||
if (change is ProgressTracker.Change.Position) {
|
||||
val tracker = change.tracker.topLevelTracker
|
||||
if (change.newStep == ProgressTracker.DONE) {
|
||||
if (change.tracker == tracker) {
|
||||
// Protocol done; schedule it for removal in a few seconds. We batch them up to make nicer
|
||||
// animations.
|
||||
println("Protocol done for ${node.info.identity.name}")
|
||||
doneTrackers += tracker
|
||||
} else {
|
||||
// Subprotocol is done; ignore it.
|
||||
}
|
||||
} else if (!trackerBoxes.containsKey(tracker)) {
|
||||
// New protocol started up; add.
|
||||
val extraLabel = simulation.extraNodeLabels[node]
|
||||
val label = if (extraLabel != null) "${node.storage.myLegalIdentity.name}: $extraLabel" else node.storage.myLegalIdentity.name
|
||||
val widget = buildProgressTrackerWidget(label, tracker.topLevelTracker)
|
||||
trackerBoxes[tracker] = widget
|
||||
sidebar.children += widget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer().scheduleAtFixedRate(0, 500) {
|
||||
Platform.runLater {
|
||||
for (tracker in doneTrackers) {
|
||||
val pane = trackerBoxes[tracker]!!
|
||||
// Slide the other tracker widgets up and over this one.
|
||||
val slideProp = SimpleDoubleProperty(0.0)
|
||||
slideProp.addListener { obv -> pane.padding = Insets(0.0, 0.0, slideProp.value, 0.0) }
|
||||
val timeline = Timeline(
|
||||
KeyFrame(Duration(250.0),
|
||||
KeyValue(pane.opacityProperty(), 0.0),
|
||||
KeyValue(slideProp, -pane.height - 50.0) // Subtract the bottom padding gap.
|
||||
)
|
||||
)
|
||||
timeline.setOnFinished {
|
||||
val vbox = trackerBoxes.remove(tracker)
|
||||
sidebar.children.remove(vbox)
|
||||
}
|
||||
timeline.play()
|
||||
}
|
||||
doneTrackers.clear()
|
||||
}
|
||||
}
|
||||
val sp = ScrollPane(sidebar)
|
||||
sp.isFitToWidth = true
|
||||
sp.isFitToHeight = true
|
||||
sp.styleClass += "sidebar"
|
||||
sp.hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
sp.vbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
sp.minWidth = 0.0
|
||||
return sp
|
||||
}
|
||||
|
||||
// TODO: Extract this to a real widget.
|
||||
private fun buildProgressTrackerWidget(label: String, tracker: ProgressTracker): Pane {
|
||||
val allSteps: List<Pair<Int, ProgressTracker.Step>> = tracker.allSteps
|
||||
val stepsBox = VBox().apply {
|
||||
styleClass += "progress-tracker-widget-steps"
|
||||
}
|
||||
for ((indent, step) in allSteps) {
|
||||
val stepLabel = Label(step.label).apply { padding = Insets(0.0, 0.0, 0.0, indent * 15.0) }
|
||||
stepsBox.children += StackPane(stepLabel)
|
||||
}
|
||||
val arrowSize = 7.0
|
||||
val cursor = Polygon(-arrowSize, -arrowSize, arrowSize, 0.0, -arrowSize, arrowSize).apply {
|
||||
styleClass += "progress-tracker-cursor"
|
||||
}
|
||||
val cursorBox = Pane(cursor).apply {
|
||||
styleClass += "progress-tracker-cursor-box"
|
||||
minWidth = 25.0
|
||||
}
|
||||
var curStep = allSteps.indexOfFirst { it.second == tracker.currentStep }
|
||||
Platform.runLater {
|
||||
val stepHeight = cursorBox.height / allSteps.size
|
||||
cursor.translateY = (curStep * stepHeight) + 20.0
|
||||
}
|
||||
val vbox: VBox?
|
||||
tracker.changes.observeOn(uiThread).subscribe { step: ProgressTracker.Change ->
|
||||
val stepHeight = cursorBox.height / allSteps.size
|
||||
if (step is ProgressTracker.Change.Position) {
|
||||
// Figure out the index of the new step.
|
||||
curStep = allSteps.indexOfFirst { it.second == step.newStep }
|
||||
// Animate the cursor to the right place.
|
||||
with(TranslateTransition(Duration(350.0), cursor)) {
|
||||
fromY = cursor.translateY
|
||||
toY = (curStep * stepHeight) + 22.5
|
||||
play()
|
||||
}
|
||||
} else if (step is ProgressTracker.Change.Structural) {
|
||||
val new = buildProgressTrackerWidget(label, tracker)
|
||||
val prevWidget = trackerBoxes[step.tracker] ?: throw AssertionError("No previous widget for tracker")
|
||||
val i = (prevWidget.parent as VBox).children.indexOf(trackerBoxes[step.tracker])
|
||||
(prevWidget.parent as VBox).children[i] = new
|
||||
trackerBoxes[step.tracker] = new
|
||||
}
|
||||
}
|
||||
HBox.setHgrow(stepsBox, Priority.ALWAYS)
|
||||
val content = HBox(cursorBox, stepsBox)
|
||||
// Make the title bar
|
||||
val title = Label(label).apply { styleClass += "sidebar-title-label" }
|
||||
StackPane.setAlignment(title, Pos.CENTER_LEFT)
|
||||
vbox = VBox(StackPane(title), content)
|
||||
vbox.padding = Insets(0.0, 0.0, 25.0, 0.0)
|
||||
return vbox
|
||||
}
|
||||
|
||||
fun nodeMapCoords(node: MockNetwork.MockNode): Pair<Double, Double> {
|
||||
// For an image of the whole world, we use:
|
||||
// return node.place.coordinate.project(mapImage.fitWidth, mapImage.fitHeight, 85.0511, -85.0511, -180.0, 180.0)
|
||||
|
||||
// For Europe, our bounds are: (lng,lat)
|
||||
// bottom left: -23.2031,29.8406
|
||||
// top right: 33.0469,64.3209
|
||||
try {
|
||||
return node.place.coordinate.project(mapImage.fitWidth, mapImage.fitHeight, 64.3209, 29.8406, -23.2031, 33.0469)
|
||||
} catch(e: Exception) {
|
||||
throw Exception("Cannot project ${node.info.identity}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun nodeCircleCoords(type: NodeType, index: Int): Pair<Double, Double> {
|
||||
val stepRad: Double = when(type) {
|
||||
NodeType.BANK -> 2 * Math.PI / bankCount
|
||||
NodeType.SERVICE -> (2 * Math.PI / serviceCount)
|
||||
}
|
||||
val tangentRad: Double = stepRad * index + when(type) {
|
||||
NodeType.BANK -> 0.0
|
||||
NodeType.SERVICE -> Math.PI / 2
|
||||
}
|
||||
val radius = when (type) {
|
||||
NodeType.BANK -> Math.min(stageWidth, stageHeight) / 3.5
|
||||
NodeType.SERVICE -> Math.min(stageWidth, stageHeight) / 8
|
||||
}
|
||||
val xOffset = -220
|
||||
val yOffset = -80
|
||||
val circleX = stageWidth / 2 + xOffset
|
||||
val circleY = stageHeight / 2 + yOffset
|
||||
val x: Double = radius * Math.cos(tangentRad) + circleX;
|
||||
val y: Double = radius * Math.sin(tangentRad) + circleY;
|
||||
return Pair(x, y)
|
||||
}
|
||||
|
||||
inner class NodeWidget(val node: MockNetwork.MockNode, val innerDot: Circle, val outerDot: Circle, val longPulseDot: Circle,
|
||||
val pulseAnim: Animation, val longPulseAnim: Animation,
|
||||
val nameLabel: Label, val statusLabel: Label) {
|
||||
fun position(index: Int, nodeCoords: (node: MockNetwork.MockNode, index: Int) -> Pair<Double, Double>) {
|
||||
val (x, y) = nodeCoords(node, index)
|
||||
innerDot.centerX = x
|
||||
innerDot.centerY = y
|
||||
outerDot.centerX = x
|
||||
outerDot.centerY = y
|
||||
longPulseDot.centerX = x
|
||||
longPulseDot.centerY = y
|
||||
(nameLabel.parent as StackPane).relocate(x - 270.0, y - 10.0)
|
||||
(statusLabel.parent as StackPane).relocate(x + 20.0, y - 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
private val nodesToWidgets = HashMap<MockNetwork.MockNode, NodeWidget>()
|
||||
|
||||
private fun createNodes() {
|
||||
bankCount = simulation.banks.size
|
||||
serviceCount = simulation.serviceProviders.size + simulation.regulators.size
|
||||
for ((index, bank) in simulation.banks.withIndex()) {
|
||||
nodesToWidgets[bank] = makeNodeWidget(bank, "bank", bank.configuration.myLegalName, NodeType.BANK, index)
|
||||
}
|
||||
for ((index, service) in simulation.serviceProviders.withIndex()) {
|
||||
nodesToWidgets[service] = makeNodeWidget(service, "network-service", service.configuration.myLegalName, NodeType.SERVICE, index)
|
||||
}
|
||||
for ((index, service) in simulation.regulators.withIndex()) {
|
||||
nodesToWidgets[service] = makeNodeWidget(service, "regulator", service.configuration.myLegalName, NodeType.SERVICE, index + simulation.serviceProviders.size)
|
||||
}
|
||||
}
|
||||
|
||||
fun makeNodeWidget(forNode: MockNetwork.MockNode, type: String, label: String = "Bank of Bologna",
|
||||
nodeType: NodeType, index: Int): NodeWidget {
|
||||
fun emitRadarPulse(initialRadius: Double, targetRadius: Double, duration: Double): Pair<Circle, Animation> {
|
||||
val pulse = Circle(initialRadius).apply {
|
||||
styleClass += "node-$type"
|
||||
styleClass += "node-circle-pulse"
|
||||
}
|
||||
val animation = Timeline(
|
||||
KeyFrame(Duration.seconds(0.0),
|
||||
pulse.radiusProperty().keyValue(initialRadius),
|
||||
pulse.opacityProperty().keyValue(1.0)
|
||||
),
|
||||
KeyFrame(Duration.seconds(duration),
|
||||
pulse.radiusProperty().keyValue(targetRadius),
|
||||
pulse.opacityProperty().keyValue(0.0)
|
||||
)
|
||||
)
|
||||
return Pair(pulse, animation)
|
||||
}
|
||||
|
||||
val innerDot = Circle(10.0).apply {
|
||||
styleClass += "node-$type"
|
||||
styleClass += "node-circle-inner"
|
||||
}
|
||||
val (outerDot, pulseAnim) = emitRadarPulse(10.0, 50.0, 0.45)
|
||||
val (longPulseOuterDot, longPulseAnim) = emitRadarPulse(10.0, 100.0, 1.45)
|
||||
root.children += outerDot
|
||||
root.children += longPulseOuterDot
|
||||
root.children += innerDot
|
||||
|
||||
val nameLabel = Label(label)
|
||||
val nameLabelRect = StackPane(nameLabel).apply {
|
||||
styleClass += "node-label"
|
||||
alignment = Pos.CENTER_RIGHT
|
||||
// This magic min width depends on the longest label of all nodes we may have, which we aren't calculating.
|
||||
// TODO: Dynamically adjust it depending on the longest label to display.
|
||||
minWidth = 250.0
|
||||
}
|
||||
root.children += nameLabelRect
|
||||
|
||||
val statusLabel = Label("")
|
||||
val statusLabelRect = StackPane(statusLabel).apply { styleClass += "node-status-label" }
|
||||
root.children += statusLabelRect
|
||||
|
||||
val widget = NodeWidget(forNode, innerDot, outerDot, longPulseOuterDot, pulseAnim, longPulseAnim, nameLabel, statusLabel)
|
||||
when (displayStyle) {
|
||||
Style.CIRCLE -> widget.position(index, { node, index -> nodeCircleCoords(nodeType, index) } )
|
||||
Style.MAP -> widget.position(index, { node, index -> nodeMapCoords(node) })
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
private fun fireBulletBetweenNodes(senderNode: MockNetwork.MockNode, destNode: MockNetwork.MockNode, startType: String, endType: String) {
|
||||
val sx = nodesToWidgets[senderNode]!!.innerDot.centerX
|
||||
val sy = nodesToWidgets[senderNode]!!.innerDot.centerY
|
||||
val dx = nodesToWidgets[destNode]!!.innerDot.centerX
|
||||
val dy = nodesToWidgets[destNode]!!.innerDot.centerY
|
||||
|
||||
val bullet = Circle(3.0)
|
||||
bullet.styleClass += "bullet"
|
||||
bullet.styleClass += "connection-$startType-to-$endType"
|
||||
with(TranslateTransition(stepDuration, bullet)) {
|
||||
fromX = sx
|
||||
fromY = sy
|
||||
toX = dx
|
||||
toY = dy
|
||||
setOnFinished {
|
||||
// For some reason removing/adding the bullet nodes causes an annoying 1px shift in the map view, so
|
||||
// to avoid visual distraction we just deliberately leak the bullet node here. Obviously this is a
|
||||
// memory leak that would break long term usage.
|
||||
//
|
||||
// TODO: Find root cause and fix.
|
||||
//
|
||||
// root.children.remove(bullet)
|
||||
bullet.isVisible = false
|
||||
}
|
||||
play()
|
||||
}
|
||||
|
||||
val line = Line(sx, sy, dx, dy).apply { styleClass += "message-line" }
|
||||
// Fade in quick, then fade out slow.
|
||||
with(FadeTransition(stepDuration.divide(5.0), line)) {
|
||||
fromValue = 0.0
|
||||
toValue = 1.0
|
||||
play()
|
||||
setOnFinished {
|
||||
with(FadeTransition(stepDuration.multiply(6.0), line)) { fromValue = 1.0; toValue = 0.0; play() }
|
||||
}
|
||||
}
|
||||
|
||||
root.children.add(1, line)
|
||||
root.children.add(bullet)
|
||||
}
|
||||
|
||||
private fun reloadStylesheet(stage: Stage) {
|
||||
stage.scene.stylesheets.clear()
|
||||
|
||||
// Enable hot reload without needing to rebuild.
|
||||
val mikesCSS = "/Users/mike/Source/R3/r3dlg-prototyping/network-explorer/src/main/resources/com/r3cev/corda/netmap/styles.css"
|
||||
if (Files.exists(Paths.get(mikesCSS)))
|
||||
stage.scene.stylesheets.add("file://$mikesCSS")
|
||||
else
|
||||
stage.scene.stylesheets.add(NetworkMapVisualiser::class.java.getResource("styles.css").toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current display style. MUST only be called on the UI
|
||||
* thread.
|
||||
*/
|
||||
fun updateDisplayStyle(style: Style) {
|
||||
displayStyle = style
|
||||
requireNotNull(splitter)
|
||||
splitter.items.remove(scrollPane!!)
|
||||
val backgroundColor: Color = mapImage.image.pixelReader.getColor(0, 0)
|
||||
scrollPane = buildScrollPane(backgroundColor)
|
||||
splitter.items.add(scrollPane!!)
|
||||
splitter.dividers[0].position = 0.3
|
||||
repositionNodes()
|
||||
bindHideButtonPosition()
|
||||
mapImage.isVisible = when (style) {
|
||||
Style.MAP -> true
|
||||
Style.CIRCLE -> false
|
||||
}
|
||||
stage.scene.accelerators[KeyCodeCombination(KeyCode.SPACE)] = Runnable { onNextInvoked() }
|
||||
// TODO: Can any current bullets be re-routed in flight?
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
@ -0,0 +1,222 @@
|
||||
package com.r3cev.corda.netmap
|
||||
|
||||
import com.r3corda.core.utilities.ProgressTracker
|
||||
import com.r3corda.node.internal.testing.IRSSimulation
|
||||
import com.r3corda.node.internal.testing.MockNetwork
|
||||
import javafx.animation.*
|
||||
import javafx.geometry.Pos
|
||||
import javafx.scene.control.Label
|
||||
import javafx.scene.layout.Pane
|
||||
import javafx.scene.layout.StackPane
|
||||
import javafx.scene.shape.Circle
|
||||
import javafx.scene.shape.Line
|
||||
import javafx.util.Duration
|
||||
import java.util.*
|
||||
|
||||
class VisualiserModel() {
|
||||
enum class Style {
|
||||
MAP, CIRCLE;
|
||||
|
||||
override fun toString(): String {
|
||||
return name.toLowerCase().capitalize()
|
||||
}
|
||||
}
|
||||
|
||||
inner class NodeWidget(val node: MockNetwork.MockNode, val innerDot: Circle, val outerDot: Circle, val longPulseDot: Circle,
|
||||
val pulseAnim: Animation, val longPulseAnim: Animation,
|
||||
val nameLabel: Label, val statusLabel: Label) {
|
||||
fun position(index: Int, nodeCoords: (node: MockNetwork.MockNode, index: Int) -> Pair<Double, Double>) {
|
||||
val (x, y) = nodeCoords(node, index)
|
||||
innerDot.centerX = x
|
||||
innerDot.centerY = y
|
||||
outerDot.centerX = x
|
||||
outerDot.centerY = y
|
||||
longPulseDot.centerX = x
|
||||
longPulseDot.centerY = y
|
||||
(nameLabel.parent as StackPane).relocate(x - 270.0, y - 10.0)
|
||||
(statusLabel.parent as StackPane).relocate(x + 20.0, y - 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
internal lateinit var view: VisualiserView
|
||||
var presentationMode: Boolean = false
|
||||
var simulation = IRSSimulation(true, false, null) // Manually pumped.
|
||||
|
||||
val trackerBoxes = HashMap<ProgressTracker, Pane>()
|
||||
val doneTrackers = ArrayList<ProgressTracker>()
|
||||
val nodesToWidgets = HashMap<MockNetwork.MockNode, NodeWidget>()
|
||||
|
||||
var bankCount: Int = 0
|
||||
var serviceCount: Int = 0
|
||||
|
||||
var stepDuration = Duration.millis(500.0)
|
||||
var runningPausedState: NetworkMapVisualiser.RunningPausedState = NetworkMapVisualiser.RunningPausedState.Paused()
|
||||
|
||||
var displayStyle: Style = Style.MAP
|
||||
set(value) {
|
||||
field = value
|
||||
view.updateDisplayStyle(value)
|
||||
repositionNodes()
|
||||
view.bindHideButtonPosition()
|
||||
}
|
||||
|
||||
fun repositionNodes() {
|
||||
for ((index, bank) in simulation.banks.withIndex()) {
|
||||
nodesToWidgets[bank]!!.position(index, when (displayStyle) {
|
||||
Style.MAP -> { node, index -> nodeMapCoords(node) }
|
||||
Style.CIRCLE -> { node, index -> nodeCircleCoords(NetworkMapVisualiser.NodeType.BANK, index) }
|
||||
})
|
||||
}
|
||||
for ((index, serviceProvider) in (simulation.serviceProviders + simulation.regulators).withIndex()) {
|
||||
nodesToWidgets[serviceProvider]!!.position(index, when (displayStyle) {
|
||||
Style.MAP -> { node, index -> nodeMapCoords(node) }
|
||||
Style.CIRCLE -> { node, index -> nodeCircleCoords(NetworkMapVisualiser.NodeType.SERVICE, index) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun nodeMapCoords(node: MockNetwork.MockNode): Pair<Double, Double> {
|
||||
// For an image of the whole world, we use:
|
||||
// return node.place.coordinate.project(mapImage.fitWidth, mapImage.fitHeight, 85.0511, -85.0511, -180.0, 180.0)
|
||||
|
||||
// For Europe, our bounds are: (lng,lat)
|
||||
// bottom left: -23.2031,29.8406
|
||||
// top right: 33.0469,64.3209
|
||||
try {
|
||||
return node.place.coordinate.project(view.mapImage.fitWidth, view.mapImage.fitHeight, 64.3209, 29.8406, -23.2031, 33.0469)
|
||||
} catch(e: Exception) {
|
||||
throw Exception("Cannot project ${node.info.identity}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun nodeCircleCoords(type: NetworkMapVisualiser.NodeType, index: Int): Pair<Double, Double> {
|
||||
val stepRad: Double = when(type) {
|
||||
NetworkMapVisualiser.NodeType.BANK -> 2 * Math.PI / bankCount
|
||||
NetworkMapVisualiser.NodeType.SERVICE -> (2 * Math.PI / serviceCount)
|
||||
}
|
||||
val tangentRad: Double = stepRad * index + when(type) {
|
||||
NetworkMapVisualiser.NodeType.BANK -> 0.0
|
||||
NetworkMapVisualiser.NodeType.SERVICE -> Math.PI / 2
|
||||
}
|
||||
val radius = when (type) {
|
||||
NetworkMapVisualiser.NodeType.BANK -> Math.min(view.stageWidth, view.stageHeight) / 3.5
|
||||
NetworkMapVisualiser.NodeType.SERVICE -> Math.min(view.stageWidth, view.stageHeight) / 8
|
||||
}
|
||||
val xOffset = -220
|
||||
val yOffset = -80
|
||||
val circleX = view.stageWidth / 2 + xOffset
|
||||
val circleY = view.stageHeight / 2 + yOffset
|
||||
val x: Double = radius * Math.cos(tangentRad) + circleX;
|
||||
val y: Double = radius * Math.sin(tangentRad) + circleY;
|
||||
return Pair(x, y)
|
||||
}
|
||||
|
||||
fun createNodes() {
|
||||
bankCount = simulation.banks.size
|
||||
serviceCount = simulation.serviceProviders.size + simulation.regulators.size
|
||||
for ((index, bank) in simulation.banks.withIndex()) {
|
||||
nodesToWidgets[bank] = makeNodeWidget(bank, "bank", bank.configuration.myLegalName, NetworkMapVisualiser.NodeType.BANK, index)
|
||||
}
|
||||
for ((index, service) in simulation.serviceProviders.withIndex()) {
|
||||
nodesToWidgets[service] = makeNodeWidget(service, "network-service", service.configuration.myLegalName, NetworkMapVisualiser.NodeType.SERVICE, index)
|
||||
}
|
||||
for ((index, service) in simulation.regulators.withIndex()) {
|
||||
nodesToWidgets[service] = makeNodeWidget(service, "regulator", service.configuration.myLegalName, NetworkMapVisualiser.NodeType.SERVICE, index + simulation.serviceProviders.size)
|
||||
}
|
||||
}
|
||||
|
||||
fun makeNodeWidget(forNode: MockNetwork.MockNode, type: String, label: String = "Bank of Bologna",
|
||||
nodeType: NetworkMapVisualiser.NodeType, index: Int): NodeWidget {
|
||||
fun emitRadarPulse(initialRadius: Double, targetRadius: Double, duration: Double): Pair<Circle, Animation> {
|
||||
val pulse = Circle(initialRadius).apply {
|
||||
styleClass += "node-$type"
|
||||
styleClass += "node-circle-pulse"
|
||||
}
|
||||
val animation = Timeline(
|
||||
KeyFrame(Duration.seconds(0.0),
|
||||
pulse.radiusProperty().keyValue(initialRadius),
|
||||
pulse.opacityProperty().keyValue(1.0)
|
||||
),
|
||||
KeyFrame(Duration.seconds(duration),
|
||||
pulse.radiusProperty().keyValue(targetRadius),
|
||||
pulse.opacityProperty().keyValue(0.0)
|
||||
)
|
||||
)
|
||||
return Pair(pulse, animation)
|
||||
}
|
||||
|
||||
val innerDot = Circle(10.0).apply {
|
||||
styleClass += "node-$type"
|
||||
styleClass += "node-circle-inner"
|
||||
}
|
||||
val (outerDot, pulseAnim) = emitRadarPulse(10.0, 50.0, 0.45)
|
||||
val (longPulseOuterDot, longPulseAnim) = emitRadarPulse(10.0, 100.0, 1.45)
|
||||
view.root.children += outerDot
|
||||
view.root.children += longPulseOuterDot
|
||||
view.root.children += innerDot
|
||||
|
||||
val nameLabel = Label(label)
|
||||
val nameLabelRect = StackPane(nameLabel).apply {
|
||||
styleClass += "node-label"
|
||||
alignment = Pos.CENTER_RIGHT
|
||||
// This magic min width depends on the longest label of all nodes we may have, which we aren't calculating.
|
||||
// TODO: Dynamically adjust it depending on the longest label to display.
|
||||
minWidth = 250.0
|
||||
}
|
||||
view.root.children += nameLabelRect
|
||||
|
||||
val statusLabel = Label("")
|
||||
val statusLabelRect = StackPane(statusLabel).apply { styleClass += "node-status-label" }
|
||||
view.root.children += statusLabelRect
|
||||
|
||||
val widget = NodeWidget(forNode, innerDot, outerDot, longPulseOuterDot, pulseAnim, longPulseAnim, nameLabel, statusLabel)
|
||||
when (displayStyle) {
|
||||
Style.CIRCLE -> widget.position(index, { node, index -> nodeCircleCoords(nodeType, index) } )
|
||||
Style.MAP -> widget.position(index, { node, index -> nodeMapCoords(node) })
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
fun fireBulletBetweenNodes(senderNode: MockNetwork.MockNode, destNode: MockNetwork.MockNode, startType: String, endType: String) {
|
||||
val sx = nodesToWidgets[senderNode]!!.innerDot.centerX
|
||||
val sy = nodesToWidgets[senderNode]!!.innerDot.centerY
|
||||
val dx = nodesToWidgets[destNode]!!.innerDot.centerX
|
||||
val dy = nodesToWidgets[destNode]!!.innerDot.centerY
|
||||
|
||||
val bullet = Circle(3.0)
|
||||
bullet.styleClass += "bullet"
|
||||
bullet.styleClass += "connection-$startType-to-$endType"
|
||||
with(TranslateTransition(stepDuration, bullet)) {
|
||||
fromX = sx
|
||||
fromY = sy
|
||||
toX = dx
|
||||
toY = dy
|
||||
setOnFinished {
|
||||
// For some reason removing/adding the bullet nodes causes an annoying 1px shift in the map view, so
|
||||
// to avoid visual distraction we just deliberately leak the bullet node here. Obviously this is a
|
||||
// memory leak that would break long term usage.
|
||||
//
|
||||
// TODO: Find root cause and fix.
|
||||
//
|
||||
// root.children.remove(bullet)
|
||||
bullet.isVisible = false
|
||||
}
|
||||
play()
|
||||
}
|
||||
|
||||
val line = Line(sx, sy, dx, dy).apply { styleClass += "message-line" }
|
||||
// Fade in quick, then fade out slow.
|
||||
with(FadeTransition(stepDuration.divide(5.0), line)) {
|
||||
fromValue = 0.0
|
||||
toValue = 1.0
|
||||
play()
|
||||
setOnFinished {
|
||||
with(FadeTransition(stepDuration.multiply(6.0), line)) { fromValue = 1.0; toValue = 0.0; play() }
|
||||
}
|
||||
}
|
||||
|
||||
view.root.children.add(1, line)
|
||||
view.root.children.add(bullet)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.r3cev.corda.netmap
|
||||
|
||||
import javafx.scene.paint.Color
|
||||
|
||||
internal
|
||||
fun colorToRgb(color: Color): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
builder.append("rgb(")
|
||||
builder.append(Math.round(color.red * 256))
|
||||
builder.append(",")
|
||||
builder.append(Math.round(color.green * 256))
|
||||
builder.append(",")
|
||||
builder.append(Math.round(color.blue * 256))
|
||||
builder.append(")")
|
||||
|
||||
return builder.toString()
|
||||
}
|
@ -0,0 +1,309 @@
|
||||
package com.r3cev.corda.netmap
|
||||
|
||||
import com.r3corda.core.utilities.ProgressTracker
|
||||
import javafx.animation.KeyFrame
|
||||
import javafx.animation.Timeline
|
||||
import javafx.application.Platform
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.event.EventHandler
|
||||
import javafx.geometry.Insets
|
||||
import javafx.geometry.Pos
|
||||
import javafx.scene.Group
|
||||
import javafx.scene.Node
|
||||
import javafx.scene.Scene
|
||||
import javafx.scene.control.*
|
||||
import javafx.scene.image.Image
|
||||
import javafx.scene.image.ImageView
|
||||
import javafx.scene.input.ZoomEvent
|
||||
import javafx.scene.layout.*
|
||||
import javafx.scene.paint.Color
|
||||
import javafx.scene.shape.Polygon
|
||||
import javafx.scene.text.Font
|
||||
import javafx.stage.Stage
|
||||
import javafx.util.Duration
|
||||
import com.r3cev.corda.netmap.VisualiserModel.Style
|
||||
import javafx.scene.input.KeyCode
|
||||
import javafx.scene.input.KeyCodeCombination
|
||||
|
||||
data class TrackerWidget(val vbox: VBox, val cursorBox: Pane, val label: Label, val cursor: Polygon)
|
||||
|
||||
internal class VisualiserView() {
|
||||
// Structural elements
|
||||
lateinit var root: Pane
|
||||
lateinit var stage: Stage
|
||||
lateinit var splitter: SplitPane
|
||||
lateinit var sidebar: VBox
|
||||
lateinit var resetButton: Button
|
||||
lateinit var nextButton: Button
|
||||
lateinit var runPauseButton: Button
|
||||
lateinit var simulateInitialisationCheckbox: CheckBox
|
||||
lateinit var styleChoice: ChoiceBox<Style>
|
||||
|
||||
var dateLabel = Label("")
|
||||
var scrollPane: ScrollPane? = null
|
||||
var hideButton = Button("«").apply { styleClass += "hide-sidebar-button" }
|
||||
|
||||
// Content
|
||||
|
||||
// -23.2031,29.8406,33.0469,64.3209
|
||||
val mapImage = ImageView(Image(NetworkMapVisualiser::class.java.getResourceAsStream("Europe.jpg")))
|
||||
|
||||
// Display properties
|
||||
val backgroundColor: Color = mapImage.image.pixelReader.getColor(0, 0)
|
||||
|
||||
val stageWidth = 1024.0
|
||||
val stageHeight = 768.0
|
||||
var defaultZoom = 0.7
|
||||
|
||||
val bitmapWidth = 1900.0
|
||||
val bitmapHeight = 1900.0
|
||||
|
||||
fun setup(runningPausedState: NetworkMapVisualiser.RunningPausedState,
|
||||
displayStyle: Style,
|
||||
presentationMode: Boolean) {
|
||||
NetworkMapVisualiser::class.java.getResourceAsStream("SourceSansPro-Regular.otf").use {
|
||||
Font.loadFont(it, 120.0)
|
||||
}
|
||||
if(displayStyle == Style.MAP) {
|
||||
mapImage.onZoom = EventHandler<javafx.scene.input.ZoomEvent> { event ->
|
||||
event.consume()
|
||||
mapImage.fitWidth = mapImage.fitWidth * event.zoomFactor
|
||||
mapImage.fitHeight = mapImage.fitHeight * event.zoomFactor
|
||||
//repositionNodes()
|
||||
}
|
||||
}
|
||||
scaleMap(displayStyle);
|
||||
root = Pane(mapImage)
|
||||
root.background = Background(BackgroundFill(backgroundColor, CornerRadii.EMPTY, Insets.EMPTY))
|
||||
scrollPane = buildScrollPane(backgroundColor, displayStyle)
|
||||
|
||||
val vbox = makeTopBar(runningPausedState, displayStyle, presentationMode)
|
||||
StackPane.setAlignment(vbox, Pos.TOP_CENTER)
|
||||
|
||||
// Now build the sidebar
|
||||
val defaultSplitterPosition = 0.3
|
||||
splitter = SplitPane(makeSidebar(), scrollPane)
|
||||
splitter.styleClass += "splitter"
|
||||
Platform.runLater {
|
||||
splitter.dividers[0].position = defaultSplitterPosition
|
||||
}
|
||||
VBox.setVgrow(splitter, Priority.ALWAYS)
|
||||
|
||||
// And the left hide button.
|
||||
hideButton = makeHideButton(defaultSplitterPosition)
|
||||
|
||||
val screenStack = VBox(vbox, StackPane(splitter, hideButton))
|
||||
screenStack.styleClass += "root-pane"
|
||||
stage.scene = Scene(screenStack, backgroundColor)
|
||||
stage.width = 1024.0
|
||||
stage.height = 768.0
|
||||
}
|
||||
|
||||
fun buildScrollPane(backgroundColor: Color, displayStyle: Style): ScrollPane {
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
mapImage.fitWidth = bitmapWidth * defaultZoom
|
||||
mapImage.fitHeight = bitmapHeight * defaultZoom
|
||||
mapImage.onZoom = EventHandler<ZoomEvent> { event ->
|
||||
event.consume()
|
||||
mapImage.fitWidth = mapImage.fitWidth * event.zoomFactor
|
||||
mapImage.fitHeight = mapImage.fitHeight * event.zoomFactor
|
||||
}
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
val scaleRatio = Math.min(stageWidth / bitmapWidth, stageHeight / bitmapHeight)
|
||||
mapImage.fitWidth = bitmapWidth * scaleRatio
|
||||
mapImage.fitHeight = bitmapHeight * scaleRatio
|
||||
}
|
||||
}
|
||||
|
||||
return ScrollPane(Group(root)).apply {
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
hvalue = 0.4
|
||||
vvalue = 0.7
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
hvalue = 0.0
|
||||
vvalue = 0.0
|
||||
}
|
||||
}
|
||||
hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
vbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
isPannable = true
|
||||
isFocusTraversable = false
|
||||
style = "-fx-background-color: " + colorToRgb(backgroundColor)
|
||||
styleClass += "edge-to-edge"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun makeHideButton(defaultSplitterPosition: Double): Button {
|
||||
var hideButtonToggled = false
|
||||
hideButton.isFocusTraversable = false
|
||||
hideButton.setOnAction {
|
||||
if (!hideButtonToggled) {
|
||||
hideButton.translateXProperty().unbind()
|
||||
Timeline(
|
||||
KeyFrame(Duration.millis(500.0),
|
||||
splitter.dividers[0].positionProperty().keyValue(0.0),
|
||||
hideButton.translateXProperty().keyValue(0.0),
|
||||
hideButton.rotateProperty().keyValue(180.0)
|
||||
)
|
||||
).play()
|
||||
} else {
|
||||
bindHideButtonPosition()
|
||||
Timeline(
|
||||
KeyFrame(Duration.millis(500.0),
|
||||
splitter.dividers[0].positionProperty().keyValue(defaultSplitterPosition),
|
||||
hideButton.rotateProperty().keyValue(0.0)
|
||||
)
|
||||
).play()
|
||||
}
|
||||
hideButtonToggled = !hideButtonToggled
|
||||
}
|
||||
bindHideButtonPosition()
|
||||
StackPane.setAlignment(hideButton, Pos.TOP_LEFT)
|
||||
return hideButton
|
||||
}
|
||||
|
||||
fun bindHideButtonPosition() {
|
||||
hideButton.translateXProperty().unbind()
|
||||
hideButton.translateXProperty().bind(splitter.dividers[0].positionProperty().multiply(splitter.widthProperty()).subtract(hideButton.widthProperty()))
|
||||
}
|
||||
|
||||
fun scaleMap(displayStyle: Style) {
|
||||
when (displayStyle) {
|
||||
Style.MAP -> {
|
||||
mapImage.fitWidth = bitmapWidth * defaultZoom
|
||||
mapImage.fitHeight = bitmapHeight * defaultZoom
|
||||
}
|
||||
Style.CIRCLE -> {
|
||||
val scaleRatio = Math.min(stageWidth / bitmapWidth, stageHeight / bitmapHeight)
|
||||
mapImage.fitWidth = bitmapWidth * scaleRatio
|
||||
mapImage.fitHeight = bitmapHeight * scaleRatio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun makeSidebar(): Node {
|
||||
sidebar = VBox()
|
||||
sidebar.styleClass += "sidebar"
|
||||
sidebar.isFillWidth = true
|
||||
val sp = ScrollPane(sidebar)
|
||||
sp.isFitToWidth = true
|
||||
sp.isFitToHeight = true
|
||||
sp.styleClass += "sidebar"
|
||||
sp.hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
sp.vbarPolicy = ScrollPane.ScrollBarPolicy.NEVER
|
||||
sp.minWidth = 0.0
|
||||
return sp
|
||||
}
|
||||
|
||||
fun makeTopBar(runningPausedState: NetworkMapVisualiser.RunningPausedState,
|
||||
displayStyle: Style,
|
||||
presentationMode: Boolean): VBox {
|
||||
nextButton = Button("Next").apply {
|
||||
styleClass += "button"
|
||||
styleClass += "next-button"
|
||||
}
|
||||
runPauseButton = Button(runningPausedState.buttonLabel.toString()).apply {
|
||||
styleClass += "button"
|
||||
styleClass += "run-button"
|
||||
}
|
||||
simulateInitialisationCheckbox = CheckBox("Simulate initialisation")
|
||||
resetButton = Button("Reset").apply {
|
||||
styleClass += "button"
|
||||
styleClass += "reset-button"
|
||||
}
|
||||
|
||||
val displayStyles = FXCollections.observableArrayList<Style>()
|
||||
Style.values().forEach { displayStyles.add(it) }
|
||||
|
||||
styleChoice = ChoiceBox(displayStyles).apply {
|
||||
styleClass += "choice"
|
||||
styleClass += "style-choice"
|
||||
}
|
||||
styleChoice.value = displayStyle
|
||||
|
||||
val dropShadow = Pane().apply { styleClass += "drop-shadow-pane-horizontal"; minHeight = 8.0 }
|
||||
val logoImage = ImageView(javaClass.getResource("R3 logo.png").toExternalForm())
|
||||
logoImage.fitHeight = 65.0
|
||||
logoImage.isPreserveRatio = true
|
||||
val logoLabel = HBox(logoImage, VBox(
|
||||
Label("D I S T R I B U T E D L E D G E R G R O U P").apply { styleClass += "dlg-label" },
|
||||
Label("Network Simulator").apply { styleClass += "logo-label" }
|
||||
))
|
||||
logoLabel.spacing = 10.0
|
||||
HBox.setHgrow(logoLabel, Priority.ALWAYS)
|
||||
logoLabel.setPrefSize(Region.USE_COMPUTED_SIZE, Region.USE_PREF_SIZE)
|
||||
dateLabel = Label("").apply { styleClass += "date-label" }
|
||||
|
||||
// Buttons area. In presentation mode there are no controls visible and you must use the keyboard.
|
||||
val hbox = if (presentationMode) {
|
||||
HBox(logoLabel, dateLabel).apply { styleClass += "controls-hbox" }
|
||||
} else {
|
||||
HBox(logoLabel, dateLabel, simulateInitialisationCheckbox, runPauseButton, nextButton, resetButton, styleChoice).apply { styleClass += "controls-hbox" }
|
||||
}
|
||||
hbox.styleClass += "fat-buttons"
|
||||
hbox.spacing = 20.0
|
||||
hbox.alignment = Pos.CENTER_RIGHT
|
||||
hbox.padding = Insets(10.0, 20.0, 10.0, 20.0)
|
||||
val vbox = VBox(hbox, dropShadow)
|
||||
vbox.styleClass += "controls-vbox"
|
||||
vbox.setPrefSize(Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE)
|
||||
vbox.setMaxSize(Region.USE_COMPUTED_SIZE, Region.USE_PREF_SIZE)
|
||||
return vbox
|
||||
}
|
||||
|
||||
// TODO: Extract this to a real widget.
|
||||
fun buildProgressTrackerWidget(label: String, tracker: ProgressTracker): TrackerWidget {
|
||||
val allSteps: List<Pair<Int, ProgressTracker.Step>> = tracker.allSteps
|
||||
val stepsBox = VBox().apply {
|
||||
styleClass += "progress-tracker-widget-steps"
|
||||
}
|
||||
for ((indent, step) in allSteps) {
|
||||
val stepLabel = Label(step.label).apply { padding = Insets(0.0, 0.0, 0.0, indent * 15.0) }
|
||||
stepsBox.children += StackPane(stepLabel)
|
||||
}
|
||||
val arrowSize = 7.0
|
||||
val cursor = Polygon(-arrowSize, -arrowSize, arrowSize, 0.0, -arrowSize, arrowSize).apply {
|
||||
styleClass += "progress-tracker-cursor"
|
||||
}
|
||||
val cursorBox = Pane(cursor).apply {
|
||||
styleClass += "progress-tracker-cursor-box"
|
||||
minWidth = 25.0
|
||||
}
|
||||
val curStep = allSteps.indexOfFirst { it.second == tracker.currentStep }
|
||||
Platform.runLater {
|
||||
val stepHeight = cursorBox.height / allSteps.size
|
||||
cursor.translateY = (curStep * stepHeight) + 20.0
|
||||
}
|
||||
val vbox: VBox?
|
||||
HBox.setHgrow(stepsBox, Priority.ALWAYS)
|
||||
val content = HBox(cursorBox, stepsBox)
|
||||
// Make the title bar
|
||||
val title = Label(label).apply { styleClass += "sidebar-title-label" }
|
||||
StackPane.setAlignment(title, Pos.CENTER_LEFT)
|
||||
vbox = VBox(StackPane(title), content)
|
||||
vbox.padding = Insets(0.0, 0.0, 25.0, 0.0)
|
||||
return TrackerWidget(vbox, cursorBox, title, cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current display style. MUST only be called on the UI
|
||||
* thread.
|
||||
*/
|
||||
fun updateDisplayStyle(displayStyle: Style) {
|
||||
requireNotNull(splitter)
|
||||
splitter.items.remove(scrollPane!!)
|
||||
scrollPane = buildScrollPane(backgroundColor, displayStyle)
|
||||
splitter.items.add(scrollPane!!)
|
||||
splitter.dividers[0].position = 0.3
|
||||
mapImage.isVisible = when (displayStyle) {
|
||||
Style.MAP -> true
|
||||
Style.CIRCLE -> false
|
||||
}
|
||||
// TODO: Can any current bullets be re-routed in flight?
|
||||
}
|
||||
}
|
@ -5,4 +5,5 @@ include 'core'
|
||||
include 'node'
|
||||
include 'client'
|
||||
include 'experimental'
|
||||
include 'test-utils'
|
||||
include 'test-utils'
|
||||
include 'network-map-visualiser'
|
||||
|
Loading…
Reference in New Issue
Block a user