Implementazione
Scastie
Come specificato nel requisito funzionale di sistema n.1, l'applicazione deve permette di compilare il codice Scala direttamente dalla pagina web utilizzando il servizio online fornito da Scastie. Questo servizio permette di scrivere e compilare codice Scala in tempo reale, offrendo un'interfaccia semplice e intuitiva per la compilazione di programmi.
Integrazione
Il principio fondamentale che regola l'interazione tra Scastie e l'applicazione si basa sul concetto delle facade types di JavaScript. Questi tipi permettono di definire interfacce Scala che corrispondono ai tipi JavaScript, consentendo l'interoperabilità con librerie esterne.
Nel dettaglio, Scastie espone delle API accessibili tramite JavaScript, che vengono utilizzate per interagire con l'applicazione. La comunicazione tra i due avviene attraverso l'uso di js.Dynamic, una funzionalità di Scala.js che consente di interagire con oggetti JavaScript senza una tipizzazione esplicita. Per rappresentare i dati scambiati viene utilizzato il formato JSON.
Questa implementazione presenta sia vantaggi che svantaggi:
- Vantaggi: Il codice di Scastie è completamente indipendente e può essere utilizzato per integrare qualsiasi libreria di aggregate computing, a condizione che rispetti il trait e il formato JSON previsto.
- Svantaggi: L'uso di JSON implica la necessità di effettuare il parsing dei dati, un'operazione che può risultare onerosa. Inoltre, l'uso di js.Dynamic può rendere il codice più difficile da leggere e mantenere, in quanto non fornisce informazioni sul tipo degli oggetti.
Come viene importato:
val engine = js.Dynamic.global.EngineImpl(
xVar.now(),
yVar.now(),
zVar.now(),
distXVar.now(),
distYVar.now(),
distZVar.now(),
edgeDistVar.now()
)
Di seguito un esempio di parte del boilerplate caricato e compilato tramite Scastie:
type Id = Int
type Color = Int
type Label = String
final case class Position(x: Double, y: Double, z: Double)
final case class Node(id: Id, position: Position, label: Label, color: Color)
trait EngineApi:
def executeIterations(): Unit
def getNodes(): js.Array[js.Dynamic]
def getEdges(): js.Array[js.Dynamic]
@JSExportTopLevel("EngineImpl")
case class EngineImpl(ncols: Int, nrows: Int, ndepth: Int)(
stepx: Int,
stepy: Int,
stepz: Int
)(proximityThreshold: Int) extends EngineApi: ...
Caricamento del codice
L'editor online e il codice vengono caricati nella pagina tramite JavaScript. Questo permette di integrare facilmente Scastie nell'applicazione, consentendo agli utenti di compilare e visualizzare il codice direttamente dalla pagina web.
scastie.Embedded('#code', {
user: user,
base64UUID: base64UUID,
update: parseInt(update)
});
Per come funziona Scastie, una volta che il codice è stato compilato, e quindi è pronto ad essere usato, viene generato un evento che viene intercettato dall'applicazione per caricare il motore.
def newScastieLoadingSignal(
result: scala.scalajs.js.Any,
attachedElements: scala.scalajs.js.Any,
scastieId: scala.scalajs.js.Any
): Unit =
engineController.loadEngine()
scene.centerView()
scala.scalajs.js.Dynamic.global.scastie.ClientMain.signal =
newScastieLoadingSignal
Domanin
Di seguito il cuore dell'applicazione, il dominio, che definisce i tipi e le strutture dati utilizzate. Questo modulo è progettato per essere indipendente dall'implementazione specifica del motore di rendering, consentendo di riutilizzare il codice a prescindere dal formato del motore. Da notare quindi che sono stati usati solo tipi primitivi evitando qualunque riferimento a librerie esterne.
sealed trait GraphType:
type Id = Int
type Color = Int
type Label = String
object GraphDomain extends GraphType:
final case class Position(x: Double, y: Double, z: Double)
final case class GraphNode(
id: Id,
position: Position,
label: Label,
color: Color
)
final case class GraphEdge(nodes: (GraphNode, GraphNode)):
override def equals(obj: Any): Boolean = obj match
case that: GraphEdge =>
this.nodes == that.nodes || this.nodes == that.nodes.swap
case _ => false
sealed trait GraphCommand
case class SetNodes(nodes: Set[GraphNode]) extends GraphCommand
case class SetEdges(edges: Set[GraphEdge]) extends GraphCommand
case class SetEdgesByIds(edgesIds: Set[(Id, Id)]) extends GraphCommand
object AnimationDomain:
enum ViewMode:
case Mode2D, Mode3D
sealed trait AnimationCommand[Engine]
case class SetEngine[Engine](engine: Engine) extends AnimationCommand[Engine]
case class StartAnimation[Engine]() extends AnimationCommand[Engine]
Estensione del dominio
Per evitare di appesantire il dominio con funzionalità non strettamente legate alla rappresentazione del grafo, sono state utilizzate le extension methods per aggiungere funzionalità aggiuntive ai tipi definiti nel dominio. Questo approccio permette di estendere le funzionalità dei tipi senza modificarli direttamente, mantenendo così il codice più pulito e modulare.
object DomainExtensions:
extension (edge: GraphEdge)
def object3dName: String =
val (n1, n2) = edge.nodes
val (minId, maxId) =
if n1.id < n2.id then (n1.id, n2.id) else (n2.id, n1.id)
s"edge-$minId-$maxId-$n1-$n2"
extension (node: GraphNode)
def object3dName: String = s"node-${node.id}"
Uso dei Type Alias
Nella parte relativa a GraphType, vengono definiti degli alias (Id, Color, Label) per rappresentare tipi comunemente utilizzati, come Int e String. Questo approccio migliora la leggibilità e l'auto-documentazione del codice, permettendo di distinguere semanticamente i diversi utilizzi di tipi primitivi all'interno del dominio.
Engine
Nel' AnimationDomain, il tipo generico [Engine] viene introdotto per evitare dipendenze forti con un motore di rendering o un'implementazione specifica. Questo approccio consente al dominio di rimanere indipendente e riutilizzabile con qualsiasi tipo di "engine" che si voglia integrare.
State
Il modulo State definisce lo stato reattivo dell'applicazione. Questo modulo è progettato per essere indipendente dall'implementazione specifica del motore di rendering, consentendo di riutilizzare il codice a prescindere dal formato del motore. Lo stato reattivo è implementato utilizzando la libreria Laminar, che fornisce un'implementazione reattiva degli oggetti.
trait GraphState:
val nodes: StrictSignal[Set[GraphNode]]
val edges: StrictSignal[Set[GraphEdge]]
val commandObserver: Observer[GraphCommand]
object GraphState extends GraphState:
private val nodesVar: Var[Set[GraphNode]] = Var(Set.empty[GraphNode])
private val edgesVar: Var[Set[GraphEdge]] = Var(Set.empty[GraphEdge])
override val nodes: StrictSignal[Set[GraphNode]] = nodesVar.signal
override val edges: StrictSignal[Set[GraphEdge]] = edgesVar.signal
override val commandObserver: Observer[GraphCommand] =
Observer[GraphCommand] {
case SetNodes(newNodes) =>
nodesVar.set(newNodes)
}
Questo codice definisce uno stato reattivo per un grafo, evidenziando come all'esterno sia possibile solo osservare lo stato e inviare comandi per modificarlo.
Engine Loop
override def start(): Unit =
def loop(): Unit =
if running.now() then
val batchCount = batch.now()
for _ <- 1 to batchCount do getEngineOrEmpty.executeIterations()
animationObserver.onNext(NextTickAdd(batchCount + 1))
handleNewData(processNextBatch())
setTimeout(() => loop(), loopInterval)
loop()
Questa funzione gestisce il loop di animazione, che viene eseguito ricorsivamente finché il flag running
è impostato su true
. Ad ogni iterazione, esegue un numero di batch definito dalla variabile reattiva batch
, permettendo così di regolare dinamicamente la velocità di esecuzione dell'animazione. È importante notare che la chiamata ricorsiva viene effettuata tramite setTimeout
, garantendo che il thread principale rimanga non bloccato, consentendo al sistema di gestire altre operazioni. Al contrario il ciclo regolato dal batch blocca l'event loop, quindi impostare un batch troppo grande potrebbe causare rallentamenti significativi.
Three.js Types e Adapter
Per l'implementazione del grafo 3D è stata scelta Three.js, una delle librerie più popolari in JavaScript per la creazione e gestione di scene e oggetti tridimensionali. Questa libreria offre un'ampia gamma di funzionalità, rendendola ideale per la visualizzazione e l'interazione con grafi in un contesto 3D.
Durante il processo di tipizzazione da JavaScript a Scala, sono stati incontrati problemi nella risoluzione completa dell'albero dei tipi utilizzando i comandi npm install --save @types/three
e successivamente sbt fastLinkJS
.
Per ovviare a queste limitazioni, è stato necessario adottare due strategie:
- Aliasing: sono stati definiti degli alias per rappresentare in Scala alcuni tipi complessi o mancanti di Three.js, semplificando la loro gestione.
- Casting: È stato utilizzato il casting esplicito per adattare i tipi dinamici di JavaScript alle strutture tipizzate di Scala.
Questo ha permesso di sfruttare caratteristiche avanzate di Scala, come il pattern matching, mantenendo comunque la compatibilità con la libreria Three.js. Questa soluzione ha consentito di integrare Three.js nell'applicazione senza rinunciare ai vantaggi offerti dal sistema di tipi di Scala.
type GenericObject3D = Object3D[Object3DEventMap]
type ThreeGroup = Group[Object3DEventMap]
type ThreePoints =
Points[BufferGeometry[Nothing], PointsMaterial, Object3DEventMap]
object ThreeType:
def unsafeCast[T](obj: js.Any): T = obj.asInstanceOf[T]
object GenericObject3D:
def unapply(obj: Object3D[?]): Option[GenericObject3D] =
Some(obj.asInstanceOf[GenericObject3D])
object ThreeCamera:
def unapply(cam: PerspectiveCamera): Option[ThreeCamera] = cam.`type` match
case "PerspectiveCamera" => Some(cam.asInstanceOf[ThreeCamera])
case "Camera" => Some(cam.asInstanceOf[ThreeCamera])
case _ => None
object Group:
def unapply(obj: GenericObject3D): Option[ThreeGroup] = obj.`type` match
case "Group" => Some(obj.asInstanceOf[ThreeGroup])
case _ => None
object Line:
def unapply(obj: GenericObject3D): Option[ThreeLine] = obj.`type` match
case "Line" => Some(obj.asInstanceOf[ThreeLine])
case _ => None
def removeObject(obj: GenericObject3D): Unit =
import ThreeType._
obj match
case Group(group) =>
for
child <- group.children
_ <- child match
case Line(line) =>
line.geometry.dispose()
line.material.dispose()
underlying.remove(line)
case Points(points) =>
....
case Sprite(sprite) =>
sprite.geometry.dispose()
sprite.material.dispose()
sprite.material.map.dispose()
underlying.remove(sprite)
case _ => ()
do underlying.remove(group)
case _ => ()
Nel codice viene wrappata la funzione genericaremove
con removeObject
per eliminare definitivamente un oggetto 3D dalla scena. Viene utilizzato il pattern matching per identificare il tipo dell'oggetto e procedere con la rimozione in base alla sua tipologia. Questo approccio consente di gestire in modo efficiente la rimozione di oggetti, come gruppi di oggetti o linee, garantendo la corretta liberazione della memoria.
Ottimizzazione del rendering
Per come è strutturato il dominio, gli unici comandi disponibili sono SetNodes
e SetEdges
, andando quindi a caricare ogni volta l'intero grafo. Questo approccio, seppur semplice, può risultare inefficiente in caso di grafi molto grandi, in quanto richiede di ricaricare l'intero grafo ad ogni aggiornamento. Per questo motivo, lo stato del grafo si tiene in memoria delle copie degli oggetti già caricati, in modo da evitare di ricaricare oggetti presenti che non sono stati modificati. Avere questo approccio è stato fondamentale per rispettare il requisito funzionale di sistema n.7, ovvero supportare più di 30 aggiornamenti al secondo.
override def setNodes(newNodes: Set[GraphNode]): Unit =
val (nodesToAdd, nodesToRemove) = calculateNodeDiff(newNodes)
removeNodes(nodesToRemove)
addNodes(nodesToAdd)
state = state.copy(currentNodes = newNodes)
private def addNodes(nodesToAdd: Set[GraphNode]): Unit =
val newObjects =
for
node <- nodesToAdd.toSeq
nodeObject = NodeFactory(node)
yield
sceneWrapper.addObject(nodeObject)
node.object3dName -> nodeObject
state = state.copy(nodeObjects = state.nodeObjects ++ newObjects)
Laminar View
val rootElement = div(
scene.renderScene("three_canvas"),
sceneController.render,
animationController.render,
engineSettings.render,
running --> (isRunning => if isRunning then player.start()),
engine --> (maybeEngine =>
maybeEngine.foreach(_ => player.loadNextFrame())
),
edges.combineWith(nodes) --> { case (es, ns) =>
scene.setNodes(ns)
scene.setEdges(es)
},
onMountCallback(_ => initialize())
)
Questo è il punto di ingresso dell'applicazione, dove viene definita la struttura della pagina web. Viene utilizzata la libreria Laminar per la creazione della vista, che permette di definire in modo dichiarativo la struttura del DOM e le interazioni tra i vari componenti. In particolare, vengono definiti i componenti principali della vista, come la scena 3D, i controlli per l'animazione e le impostazioni del motore di rendering. La vista viene aggiornata in modo reattivo in base allo stato dell'applicazione, garantendo una corretta sincronizzazione tra i dati e la rappresentazione grafica.