Network view improvement (#19)

Changes :
*Show other nodes on network map.
*Enabled zooming and panning on the map.
*Scroll the node label to the centre of the screen when clicking on the node info list on the right hand-side of the screen.
*Draw line and fire bullets between nodes according to incoming transactions.
*Higher resolution map.
This commit is contained in:
Patrick Kuo 2016-12-07 09:55:16 +00:00 committed by GitHub
parent bddb22db77
commit fbcbf3e1d7
6 changed files with 205 additions and 63 deletions

View File

@ -107,10 +107,10 @@ fun main(args: Array<String>) {
driver(portAllocation = portAllocation) {
val user = User("user1", "test", permissions = setOf(startFlowPermission<CashFlow>()))
// TODO : Supported flow should be exposed somehow from the node instead of set of ServiceInfo.
val notary = startNode("Notary", advertisedServices = setOf(ServiceInfo(SimpleNotaryService.type)))
val alice = startNode("Alice", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))))
val bob = startNode("Bob", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))))
val issuer = startNode("Royal Mint", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))))
val notary = startNode("Notary", advertisedServices = setOf(ServiceInfo(SimpleNotaryService.type)), customOverrides = mapOf("nearestCity" to "Zurich"))
val alice = startNode("Alice", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))), customOverrides = mapOf("nearestCity" to "Paris"))
val bob = startNode("Bob", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))), customOverrides = mapOf("nearestCity" to "Frankfurt"))
val issuer = startNode("Royal Mint", rpcUsers = arrayListOf(user), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))), customOverrides = mapOf("nearestCity" to "London"))
val notaryNode = notary.get()
val aliceNode = alice.get()

View File

@ -81,3 +81,6 @@ fun EventTarget.copyableLabel(value: ObservableValue<String>? = null, op: (TextF
}
inline fun <reified M : Any> View.getModel(): M = Models.get(M::class, this.javaClass.kotlin)
// Cartesian product of 2 collections.
fun <A, B> Collection<A>.cross(other: Collection<B>) = this.flatMap { a -> other.map { b -> a to b } }

View File

@ -2,99 +2,213 @@ package net.corda.explorer.views
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView
import javafx.animation.FadeTransition
import javafx.animation.TranslateTransition
import javafx.beans.binding.Bindings
import javafx.beans.property.SimpleObjectProperty
import javafx.scene.Node
import javafx.collections.FXCollections
import javafx.geometry.Bounds
import javafx.geometry.Point2D
import javafx.scene.Parent
import javafx.scene.control.Button
import javafx.scene.control.ContentDisplay
import javafx.scene.control.Label
import javafx.scene.control.ScrollPane
import javafx.scene.image.ImageView
import javafx.scene.layout.BorderPane
import javafx.scene.layout.Pane
import javafx.scene.layout.VBox
import javafx.scene.shape.Circle
import javafx.scene.shape.Line
import javafx.scene.text.Font
import javafx.scene.text.FontWeight
import net.corda.client.fxutils.map
import net.corda.client.model.NetworkIdentityModel
import net.corda.client.model.observableList
import net.corda.client.model.observableValue
import javafx.util.Duration
import net.corda.client.fxutils.*
import net.corda.client.model.*
import net.corda.core.contracts.ContractState
import net.corda.core.crypto.Party
import net.corda.core.node.NodeInfo
import net.corda.explorer.model.CordaView
import tornadofx.*
// TODO : Construct a node map using node info and display them on a world map.
// TODO : Allow user to see transactions between nodes on a world map.
class Network : CordaView() {
override val root by fxml<Parent>()
override val icon = FontAwesomeIcon.GLOBE
// Inject data.
val myIdentity by observableValue(NetworkIdentityModel::myIdentity)
val notaries by observableList(NetworkIdentityModel::notaries)
val peers by observableList(NetworkIdentityModel::parties)
// Components
val transactions by observableList(GatheredTransactionDataModel::partiallyResolvedTransactions)
// UI components
private val myIdentityPane by fxid<BorderPane>()
private val notaryList by fxid<VBox>()
private val peerList by fxid<VBox>()
private val mapScrollPane by fxid<ScrollPane>()
private val mapPane by fxid<Pane>()
private val mapImageView by fxid<ImageView>()
private val zoomInButton by fxid<Button>()
private val zoomOutButton by fxid<Button>()
// Create a strong ref to prevent GC.
private val notaryButtons = notaries.map { it.render() }
private val peerButtons = peers.filtered { it != myIdentity.value }.map { it.render() }
private val coordinate = Bindings.createObjectBinding({
myIdentity.value?.physicalLocation?.coordinate?.project(mapPane.width, mapPane.height, 85.0511, -85.0511, -180.0, 180.0)?.let {
Pair(it.first - 15, it.second - 10)
private val mapOriginalHeight = 2000.0
// UI node observables, declare here to create a strong ref to prevent GC, which removes listener from observables.
private val notaryComponents = notaries.map { it.render() }
private val notaryButtons = notaryComponents.map { it.button }
private val peerComponents = peers.map { it.render() }
private val peerButtons = peerComponents.filtered { it.nodeInfo != myIdentity.value }.map { it.button }
private val myComponent = myIdentity.map { it?.render() }
private val myButton = myComponent.map { it?.button }
private val myMapLabel = myComponent.map { it?.label }
private val allComponents = FXCollections.observableArrayList(notaryComponents, peerComponents).concatenate()
private val allComponentMap = allComponents.associateBy { it.nodeInfo.legalIdentity }
private val mapLabels = allComponents.map { it.label }
private data class MapViewComponents(val nodeInfo: NodeInfo, val button: Button, val label: Label)
private val stepDuration = Duration.millis(500.0)
private val lastTransactions = transactions.last().map {
it?.let {
val inputParties = it.inputs.sequence()
.map { it as? PartiallyResolvedTransaction.InputResolution.Resolved }
.filterNotNull()
.map { it.stateAndRef.state.data }.getParties()
val outputParties = it.transaction.tx.outputs.map { it.data }.observable().getParties()
val signingParties = it.transaction.sigs.map { getModel<NetworkIdentityModel>().lookup(it.by) }
// Input parties fire a bullets to all output parties, and to the signing parties. !! This is a rough guess of how the message moves in the network.
// TODO : Expose artemis queue to get real message information.
inputParties.cross(outputParties) + inputParties.cross(signingParties)
}
}, arrayOf(mapPane.widthProperty(), mapPane.heightProperty(), myIdentity))
}
private fun NodeInfo.render(): Node {
return button {
private fun NodeInfo.render(): MapViewComponents {
val node = this
val mapLabel = label(node.legalIdentity.name) {
graphic = FontAwesomeIconView(FontAwesomeIcon.DOT_CIRCLE_ALT)
contentDisplay = ContentDisplay.TOP
val coordinate = Bindings.createObjectBinding({
// These coordinates are obtained when we generate the map using TileMill.
node.physicalLocation?.coordinate?.project(mapPane.width, mapPane.height, 85.0511, -85.0511, -180.0, 180.0) ?: Pair(0.0, 0.0)
}, arrayOf(mapPane.widthProperty(), mapPane.heightProperty()))
// Center point of the label.
layoutXProperty().bind(coordinate.map { it.first - width / 2 })
layoutYProperty().bind(coordinate.map { it.second - height / 4 })
}
val button = button {
graphic = vbox {
label(this@render.legalIdentity.name) {
font = Font.font(font.family, FontWeight.BOLD, 15.0)
}
label(node.legalIdentity.name) { font = Font.font(font.family, FontWeight.BOLD, 15.0) }
gridpane {
hgap = 5.0
vgap = 5.0
row("Pub Key :") {
copyableLabel(SimpleObjectProperty(this@render.legalIdentity.owningKey.toBase58String()))
}
row("Services :") {
label(this@render.advertisedServices.map { it.info }.joinToString(", "))
}
this@render.physicalLocation?.apply {
row("Location :") {
label(this@apply.description)
}
row("Pub Key :") { copyableLabel(SimpleObjectProperty(node.legalIdentity.owningKey.toBase58String())) }
row("Services :") { label(node.advertisedServices.map { it.info }.joinToString(", ")) }
node.physicalLocation?.apply { row("Location :") { label(this@apply.description) } }
}
}
setOnMouseClicked { mapScrollPane.centerLabel(mapLabel) }
}
return MapViewComponents(this, button, mapLabel)
}
init {
myIdentityPane.centerProperty().bind(myButton)
Bindings.bindContent(notaryList.children, notaryButtons)
Bindings.bindContent(peerList.children, peerButtons)
Bindings.bindContent(mapPane.children, mapLabels)
// Run once when the screen is ready.
// TODO : Find a better way to do this.
mapPane.heightProperty().addListener { _o, old, _new ->
if (old == 0.0) myMapLabel.value?.let { mapScrollPane.centerLabel(it) }
}
// Listen on zooming gesture, if device has gesture support.
mapPane.setOnZoom { zoom(it.zoomFactor, Point2D(it.x, it.y)) }
// Zoom controls for the map.
zoomInButton.setOnAction { zoom(1.2) }
zoomOutButton.setOnAction { zoom(0.8) }
lastTransactions.addListener { observableValue, old, new ->
new?.forEach {
it.first.value?.let { a ->
it.second.value?.let { b ->
fireBulletBetweenNodes(a.legalIdentity, b.legalIdentity, "bank", "bank")
}
}
}
}
}
init {
myIdentityPane.centerProperty().bind(myIdentity.map { it?.render() })
Bindings.bindContent(notaryList.children, notaryButtons)
Bindings.bindContent(peerList.children, peerButtons)
val myLocation = Label("", FontAwesomeIconView(FontAwesomeIcon.DOT_CIRCLE_ALT)).apply { contentDisplay = ContentDisplay.TOP }
myLocation.textProperty().bind(myIdentity.map { it?.legalIdentity?.name })
myLocation.layoutXProperty().bind(coordinate.map { it?.first })
myLocation.layoutYProperty().bind(coordinate.map { it?.second })
mapPane.add(myLocation)
val scroll = Bindings.createObjectBinding({
val width = mapScrollPane.content.boundsInLocal.width
val height = mapScrollPane.content.boundsInLocal.height
val x = myLocation.boundsInParent.maxX
val y = myLocation.boundsInParent.minY
Pair(x / width, y / height)
}, arrayOf(coordinate))
mapScrollPane.vvalueProperty().bind(scroll.map { it.second })
mapScrollPane.hvalueProperty().bind(scroll.map { it.first })
private fun ScrollPane.centerLabel(label: Label) {
this.hvalue = (label.boundsInParent.width / 2 + label.boundsInParent.minX) / mapImageView.layoutBounds.width
this.vvalue = (label.boundsInParent.height / 2 + label.boundsInParent.minY) / mapImageView.layoutBounds.height
}
}
private fun zoom(zoomFactor: Double, mousePoint: Point2D = mapScrollPane.viewportBounds.center()) {
// Work out scroll bar position.
val valX = mapScrollPane.hvalue * (mapImageView.layoutBounds.width - mapScrollPane.viewportBounds.width)
val valY = mapScrollPane.vvalue * (mapImageView.layoutBounds.height - mapScrollPane.viewportBounds.height)
// Set zoom scale limit to minimum 1x and maximum 10x.
val newHeight = Math.min(Math.max(mapImageView.prefHeight(-1.0) * zoomFactor, mapOriginalHeight), mapOriginalHeight * 10)
val newZoomFactor = newHeight / mapImageView.prefHeight(-1.0)
// calculate adjustment of scroll position based on mouse location.
val adjustment = mousePoint.multiply(newZoomFactor - 1)
// Change the map size.
mapImageView.fitHeight = newHeight
mapScrollPane.layout()
// Adjust scroll.
mapScrollPane.hvalue = (valX + adjustment.x) / (mapImageView.layoutBounds.width - mapScrollPane.viewportBounds.width)
mapScrollPane.vvalue = (valY + adjustment.y) / (mapImageView.layoutBounds.height - mapScrollPane.viewportBounds.height)
}
private fun Bounds.center(): Point2D {
val x = this.width / 2 - this.minX
val y = this.height / 2 - this.minY
return Point2D(x, y)
}
private fun List<ContractState>.getParties() = map { it.participants.map { getModel<NetworkIdentityModel>().lookup(it) } }.flatten()
private fun fireBulletBetweenNodes(senderNode: Party, destNode: Party, startType: String, endType: String) {
allComponentMap[senderNode]?.let { senderNode ->
allComponentMap[destNode]?.let { destNode ->
val sender = senderNode.label.boundsInParentProperty().map { Point2D(it.width / 2 + it.minX, it.height / 4 - 2.5 + it.minY) }
val receiver = destNode.label.boundsInParentProperty().map { Point2D(it.width / 2 + it.minX, it.height / 4 - 2.5 + it.minY) }
val bullet = Circle(3.0)
bullet.styleClass += "bullet"
bullet.styleClass += "connection-$startType-to-$endType"
with(TranslateTransition(stepDuration, bullet)) {
fromXProperty().bind(sender.map { it.x })
fromYProperty().bind(sender.map { it.y })
toXProperty().bind(receiver.map { it.x })
toYProperty().bind(receiver.map { it.y })
setOnFinished { mapPane.children.remove(bullet) }
play()
}
val line = Line().apply {
styleClass += "message-line"
startXProperty().bind(sender.map { it.x })
startYProperty().bind(sender.map { it.y })
endXProperty().bind(receiver.map { it.x })
endYProperty().bind(receiver.map { it.y })
}
// 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()
setOnFinished { mapPane.children.remove(line) }
}
}
}
mapPane.children.add(1, line)
mapPane.children.add(bullet)
}
}
}
}

View File

@ -207,6 +207,7 @@
.networkView .map .label:hover .glyph-icon,
.networkView .map .label:hover .text {
-fx-fill: -color-4;
-fx-cursor: hand;
}
.networkView .map .glyph-icon,
@ -239,4 +240,20 @@
#setting-edit-label:hover .text, #setting-edit-label:hover .glyph-icon {
-fx-fill: -color-4;
}
/* Styles for firing bullets between nodes in Network view. */
.bullet {
-fx-fill: black;
}
.connection-bank-to-bank {
-fx-fill: white;
}
.message-line {
-fx-stroke: white;
}
.connection-bank-to-regulator {
-fx-stroke: red;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 561 KiB

View File

@ -8,10 +8,11 @@
xmlns:fx="http://javafx.com/fxml/1">
<center>
<StackPane>
<ScrollPane fx:id="mapScrollPane" hbarPolicy="ALWAYS" pannable="true" vbarPolicy="ALWAYS">
<Pane fx:id="mapPane" styleClass="map">
<ImageView fx:id="mapImageView" styleClass="worldMap"/>
</Pane>
<ScrollPane fx:id="mapScrollPane" hbarPolicy="NEVER" pannable="true" vbarPolicy="NEVER">
<StackPane>
<ImageView fx:id="mapImageView" styleClass="worldMap" preserveRatio="true" fitHeight="2000"/>
<Pane fx:id="mapPane" styleClass="map"/>
</StackPane>
</ScrollPane>
<VBox spacing="5" StackPane.alignment="TOP_LEFT" maxWidth="-Infinity" maxHeight="-Infinity">
<StackPane.margin>
@ -41,6 +42,13 @@
</BorderPane>
</TitledPane>
</VBox>
<VBox StackPane.alignment="BOTTOM_RIGHT" maxWidth="-Infinity" maxHeight="-Infinity" spacing="10">
<StackPane.margin>
<Insets bottom="10" right="10"/>
</StackPane.margin>
<Button fx:id="zoomInButton" text="+" maxWidth="Infinity"/>
<Button fx:id="zoomOutButton" text="-" maxWidth="Infinity"/>
</VBox>
</StackPane>
</center>
</BorderPane>