Skip to content

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:

scala

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:

scala
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.

javascript
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.

scala
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.

scala
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.

scala
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.

scala
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

scala
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.

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
scala
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.

scala

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

scala
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.