mirror of
https://github.com/corda/corda.git
synced 2025-04-07 11:27:01 +00:00
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:
parent
bddb22db77
commit
fbcbf3e1d7
@ -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()
|
||||
|
@ -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 } }
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 |
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user