diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/BaseLiveMap.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/BaseLiveMap.kt new file mode 100644 index 00000000000..4841d9ec219 --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/BaseLiveMap.kt @@ -0,0 +1,25 @@ +package jetbrains.livemap + +import jetbrains.datalore.base.observable.event.EventHandler +import jetbrains.datalore.base.observable.event.EventSource +import jetbrains.datalore.base.observable.event.SimpleEventSource +import jetbrains.datalore.base.observable.property.Property +import jetbrains.datalore.base.observable.property.ValueProperty +import jetbrains.datalore.base.registration.Disposable +import jetbrains.datalore.base.registration.Registration +import jetbrains.datalore.visualization.base.canvas.CanvasControl + +abstract class BaseLiveMap : EventSource, Disposable { + private val throwableSource = SimpleEventSource() + val isLoading: Property = ValueProperty(true) + + abstract fun draw(canvasControl: CanvasControl) + + override fun addHandler(handler: EventHandler): Registration { + return throwableSource.addHandler(handler) + } + + protected fun fireThrowable(throwable: Throwable) { + throwableSource.fire(throwable) + } +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContent.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContent.kt new file mode 100644 index 00000000000..f9c30c00f2b --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContent.kt @@ -0,0 +1,8 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.visualization.base.canvas.CanvasControl + +internal interface CanvasContent { + fun show(parentControl: CanvasControl) + fun hide() +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContentPresenter.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContentPresenter.kt new file mode 100644 index 00000000000..811e2d03769 --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/CanvasContentPresenter.kt @@ -0,0 +1,29 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.visualization.base.canvas.CanvasControl + +internal class CanvasContentPresenter { + lateinit var canvasControl: CanvasControl + private var canvasContent: CanvasContent = EMPTY_CANVAS_CONTENT + + + fun show(content: CanvasContent) { + canvasContent.hide() + canvasContent = content + canvasContent.show(canvasControl) + } + + fun clear() { + show(EMPTY_CANVAS_CONTENT) + } + + private class EmptyContent : CanvasContent { + override fun show(parentControl: CanvasControl) {} + + override fun hide() {} + } + + companion object { + private val EMPTY_CANVAS_CONTENT = EmptyContent() + } +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapContent.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapContent.kt new file mode 100644 index 00000000000..2bbfcc0f875 --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapContent.kt @@ -0,0 +1,32 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.base.observable.event.EventHandler +import jetbrains.datalore.base.observable.event.EventSource +import jetbrains.datalore.base.registration.Registration +import jetbrains.datalore.visualization.base.canvas.CanvasControl +import jetbrains.livemap.BaseLiveMap + +class LiveMapContent(private val liveMap: BaseLiveMap) : CanvasContent, EventSource { + + override fun show(parentControl: CanvasControl) { + liveMap.draw(parentControl) + } + + override fun hide() { + liveMap.dispose() + } + + override fun addHandler(handler: EventHandler): Registration { + return liveMap.addHandler(handler) + } + + fun addHandler(handler: (Throwable) -> Unit): Registration { + return liveMap.addHandler( + object : EventHandler { + override fun onEvent(event: Throwable) { + handler(event) + } + } + ) + } +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapPresenter.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapPresenter.kt new file mode 100644 index 00000000000..8ebd9d2e4ff --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/LiveMapPresenter.kt @@ -0,0 +1,79 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.base.async.Async +import jetbrains.datalore.base.observable.property.Properties +import jetbrains.datalore.base.observable.property.PropertyBinding +import jetbrains.datalore.base.observable.property.ValueProperty +import jetbrains.datalore.base.registration.Disposable +import jetbrains.datalore.base.registration.Registration +import jetbrains.datalore.visualization.base.canvas.CanvasControl +import jetbrains.livemap.BaseLiveMap + +class LiveMapPresenter : Disposable { + private val contentPresenter: CanvasContentPresenter + private var registration = Registration.EMPTY + private var isLoadingLiveMapRegistration = Registration.EMPTY + private var removed = false + + private val initializing = ValueProperty(true) + private val liveMapIsLoading = ValueProperty(true) + val isLoading = Properties.or(initializing, liveMapIsLoading) + + constructor() { + contentPresenter = CanvasContentPresenter() + } + + // for tests + internal constructor(presenter: CanvasContentPresenter) { + contentPresenter = presenter + } + + fun render(canvasControl: CanvasControl, liveMap: Async) { + contentPresenter.canvasControl = canvasControl + + showSpinner() + liveMap.onResult(::showLiveMap, ::showError) + } + + private fun showLiveMap(liveMap: BaseLiveMap) { + if (isLoadingLiveMapRegistration !== Registration.EMPTY) { + throw IllegalStateException("Unexpected") + } + + initializing.set(false) + isLoadingLiveMapRegistration = PropertyBinding.bindOneWay(liveMap.isLoading, liveMapIsLoading) + + setContent { + LiveMapContent(liveMap).also { + registration = it.addHandler(::showError) + } + } + } + + private fun showSpinner() { + initializing.set(true) + setContent(::SpinnerContent) + } + + private fun showError(throwable: Throwable) { + initializing.set(false) + liveMapIsLoading.set(false) + val message = throwable.message + setContent { MessageContent(message ?: "Undefined exception") } + } + + private fun setContent(canvasContentSupplier: () -> CanvasContent) { + if (removed) { + return + } + + contentPresenter.show(canvasContentSupplier()) + } + + override fun dispose() { + removed = true + registration.dispose() + isLoadingLiveMapRegistration.dispose() + contentPresenter.clear() + } +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/MessageContent.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/MessageContent.kt new file mode 100644 index 00000000000..ed335f49699 --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/MessageContent.kt @@ -0,0 +1,71 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.base.geometry.DoubleVector +import jetbrains.datalore.visualization.base.canvas.CanvasControl +import jetbrains.datalore.visualization.base.canvas.CanvasControlUtil.drawLater +import jetbrains.datalore.visualization.base.canvas.Context2d +import jetbrains.datalore.visualization.base.canvas.Context2d.TextAlign +import jetbrains.datalore.visualization.base.canvas.Context2d.TextBaseline +import jetbrains.datalore.visualization.base.canvas.SingleCanvasControl +import kotlin.math.max + +internal class MessageContent(private val message: String) : CanvasContent { + private lateinit var canvasControl: SingleCanvasControl + + override fun show(parentControl: CanvasControl) { + canvasControl = SingleCanvasControl(parentControl) + + with(canvasControl.createCanvas()) { + drawText(context2d, DoubleVector(size.x.toDouble(), size.y.toDouble())) + + takeSnapshot() + .onSuccess { snapshot -> + drawLater(parentControl) { + canvasControl.context.drawImage( + snapshot, + 0.0, + 0.0 + ) + } + } + } + } + + override fun hide() { + canvasControl.dispose() + } + + private fun drawText(context: Context2d, dimension: DoubleVector) = + with(context) { + val lines: List = message.split("\n") + + save() + + setFillColor(BACKGROUND_COLOR) + fillRect(0.0, 0.0, dimension.x, dimension.y) + + setTextBaseline(TextBaseline.TOP) + setTextAlign(TextAlign.LEFT) + setFillColor(FONT_COLOR) + setFont("400 " + FONT_SIZE + "px/" + FONT_HEIGHT + "px Helvetica, Arial, sans-serif") + + val height = FONT_HEIGHT * lines.size + var width = 0.0 + + lines.forEach { width = max(width, measureText(it)) } + + lines.indices.forEach { + fillText(lines[it], (dimension.x - width) / 2, (dimension.y - height) / 2 + it * FONT_HEIGHT) + } + + restore() + } + + companion object { + private const val FONT_SIZE = 17.0 + private const val FONT_HEIGHT = 21.25 + private const val FONT_COLOR = "#B3B3B3" + private const val BACKGROUND_COLOR = "#FFFFFF" + } + +} \ No newline at end of file diff --git a/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/SpinnerContent.kt b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/SpinnerContent.kt new file mode 100644 index 00000000000..84945c04010 --- /dev/null +++ b/livemap/src/commonMain/kotlin/jetbrains/livemap/canvascontrols/SpinnerContent.kt @@ -0,0 +1,109 @@ +package jetbrains.livemap.canvascontrols + +import jetbrains.datalore.base.geometry.DoubleVector +import jetbrains.datalore.base.registration.Registration +import jetbrains.datalore.visualization.base.canvas.CanvasControl +import jetbrains.datalore.visualization.base.canvas.CanvasControl.AnimationEventHandler +import jetbrains.datalore.visualization.base.canvas.CanvasControlUtil.setAnimationHandler +import jetbrains.datalore.visualization.base.canvas.Context2d +import jetbrains.datalore.visualization.base.canvas.SingleCanvasControl +import kotlin.math.PI + +internal class SpinnerContent : CanvasContent { + + private lateinit var registration: Registration + private lateinit var canvasControl: SingleCanvasControl + private lateinit var spinnerCenter: DoubleVector + + override fun show(parentControl: CanvasControl) { + canvasControl = SingleCanvasControl(parentControl) + + with(canvasControl.createCanvas()) { + context2d.drawStaticElements() + + registration = setAnimationHandler( + parentControl, + object : AnimationEventHandler { + override fun onEvent(millisTime: Long): Boolean { + context2d.drawSpinner(millisTime) + + takeSnapshot() + .onSuccess { canvasControl.context.drawImage(it, 0.0, 0.0) } + return true + } + } + ) + } + + + } + + override fun hide() { + canvasControl.dispose() + registration.dispose() + } + + private fun Context2d.drawStaticElements() { + save() + + setFont("400 " + FONT_SIZE + "px Helvetica, Arial, sans-serif") + val textWidth = measureText(LOADING_TEXT) + + val spinnerWidth = 2 * RADIUS + LINE_WIDTH + val width = spinnerWidth + SPACE + textWidth + val dimension = canvasControl.size + spinnerCenter = DoubleVector((dimension.x - width) / 2 + spinnerWidth / 2, dimension.y / 2.0) + + setFillColor(BACKGROUND_COLOR) + fillRect(0.0, 0.0, dimension.x.toDouble(), dimension.y.toDouble()) + + setTextBaseline(Context2d.TextBaseline.MIDDLE) + setTextAlign(Context2d.TextAlign.LEFT) + setFillColor(FONT_COLOR) + fillText(LOADING_TEXT, (dimension.x + width) / 2 - textWidth, dimension.y / 2.0) + + restore() + } + + private fun Context2d.drawSpinner(time: Long) { + save() + + setFillColor(BACKGROUND_COLOR) + fillRect( + spinnerCenter.x - BACK_RADIUS, + spinnerCenter.y - BACK_RADIUS, + 2 * BACK_RADIUS, + 2 * BACK_RADIUS + ) + + drawSpinnerArc(CIRCLE_COLOR, 0.0, 2 * PI) + + val angle = 2.0 * PI * (time % LOOP_DURATION).toDouble() / LOOP_DURATION + drawSpinnerArc(ARC_COLOR, angle, ARC_LENGTH) + + restore() + } + + private fun Context2d.drawSpinnerArc(color: String, startAngle: Double, arcAngle: Double) { + setLineWidth(LINE_WIDTH) + setStrokeColor(color) + beginPath() + arc(spinnerCenter.x, spinnerCenter.y, RADIUS, startAngle, startAngle + arcAngle) + stroke() + } + + companion object { + private const val BACKGROUND_COLOR = "#FFFFFF" + private const val LINE_WIDTH = 0.9 + private const val RADIUS = 11.5 + private const val BACK_RADIUS = RADIUS + LINE_WIDTH + private const val CIRCLE_COLOR = "#E8E8E8" + private const val ARC_COLOR = "#00BFFF" + private const val ARC_LENGTH = PI / 2 + private const val LOOP_DURATION: Long = 1000 + private const val SPACE = 15.0 + private const val LOADING_TEXT = "Loading..." + private const val FONT_SIZE = 12.0 + private const val FONT_COLOR = "#616161" + } +} \ No newline at end of file