diff --git a/demo/build.gradle.kts b/demo/maps/build.gradle.kts similarity index 95% rename from demo/build.gradle.kts rename to demo/maps/build.gradle.kts index b6738fd..2b70922 100644 --- a/demo/build.gradle.kts +++ b/demo/maps/build.gradle.kts @@ -33,7 +33,7 @@ compose.desktop { mainClass = "MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "maps-kt-compose" + packageName = "maps-compose-demo" packageVersion = "1.0.0" } } diff --git a/demo/src/jvmMain/kotlin/Main.kt b/demo/maps/src/jvmMain/kotlin/Main.kt similarity index 100% rename from demo/src/jvmMain/kotlin/Main.kt rename to demo/maps/src/jvmMain/kotlin/Main.kt diff --git a/demo/scheme/build.gradle.kts b/demo/scheme/build.gradle.kts new file mode 100644 index 0000000..0222e52 --- /dev/null +++ b/demo/scheme/build.gradle.kts @@ -0,0 +1,39 @@ +import org.jetbrains.compose.compose +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +val ktorVersion: String by rootProject.extra + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = "11" + } + withJava() + } + sourceSets { + val jvmMain by getting { + dependencies { + implementation(projects.schemeKt) + implementation(compose.desktop.currentOs) + implementation("ch.qos.logback:logback-classic:1.2.11") + } + } + val jvmTest by getting + } +} + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "scheme-compose-demo" + packageVersion = "1.0.0" + } + } +} diff --git a/demo/scheme/src/jvmMain/kotlin/Main.kt b/demo/scheme/src/jvmMain/kotlin/Main.kt new file mode 100644 index 0000000..321f01b --- /dev/null +++ b/demo/scheme/src/jvmMain/kotlin/Main.kt @@ -0,0 +1,47 @@ +// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import center.sciprog.compose.scheme.* + +@Composable +@Preview +fun App() { + MaterialTheme { + //create a view point + val viewPoint = remember { + SchemeViewPoint( + SchemeCoordinates(0f, 0f), + 1f + ) + } + + + SchemeView( + viewPoint, + config = SchemeViewConfig( + inferViewBoxFromFeatures = true, + onClick = { + println("${focus.x}, ${focus.y}") + } + ) + ) { + background(painterResource("middle-earth.jpg")) + circle(410.52737 to 868.7676, color = Color.Blue) + text(410.52737 to 868.7676,"Shire", color = Color.Blue) + circle(1132.0881 to 394.99127, color = Color.Red) + text(1132.0881 to 394.99127, "Ordruin",color = Color.Red) + } + } +} + +fun main() = application { + Window(onCloseRequest = ::exitApplication) { + App() + } +} diff --git a/scheme-kt/build.gradle.kts b/scheme-kt/build.gradle.kts new file mode 100644 index 0000000..285b7ab --- /dev/null +++ b/scheme-kt/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.compose.compose +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +group = "center.sciprog" +version = "1.0-SNAPSHOT" + +repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") +} + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = "11" + } + withJava() + } + sourceSets { + commonMain{ + dependencies{ + api("io.github.microutils:kotlin-logging:2.1.23") + api(compose.foundation) + } + } + val jvmMain by getting { + dependencies { + api(compose.desktop.currentOs) + implementation("ch.qos.logback:logback-classic:1.2.11") + } + } + val jvmTest by getting + } +} + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "compose-scheme" + packageVersion = "1.0.0" + } + } +} diff --git a/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/FeatureBuilder.kt b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/FeatureBuilder.kt new file mode 100644 index 0000000..a025820 --- /dev/null +++ b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/FeatureBuilder.kt @@ -0,0 +1,131 @@ +package center.sciprog.compose.scheme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import center.sciprog.compose.scheme.SchemeFeature.Companion.defaultScaleRange + +typealias FeatureId = String + +interface FeatureBuilder { + fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId + + fun build(): SnapshotStateMap +} + +internal class SchemeFeatureBuilder(initialFeatures: Map) : FeatureBuilder { + + private val content: SnapshotStateMap = + mutableStateMapOf().apply { + putAll(initialFeatures) + } + + private fun generateID(feature: SchemeFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]" + + override fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId { + val safeId = id ?: generateID(feature) + content[id ?: generateID(feature)] = feature + return safeId + } + + override fun build(): SnapshotStateMap = content +} + +fun FeatureBuilder.background( + painter: Painter, + box: SchemeCoordinateBox, + id: FeatureId? = null, +): FeatureId = addFeature( + id, + SchemeBackgroundFeature(box, painter) +) + +fun FeatureBuilder.background( + painter: Painter, + size: Size = painter.intrinsicSize, + offset: SchemeCoordinates = SchemeCoordinates(0f, 0f), + id: FeatureId? = null, +): FeatureId { + val box = SchemeCoordinateBox( + offset, + SchemeCoordinates(size.width + offset.x, size.height + offset.y) + ) + return background(painter, box, id) +} + +fun FeatureBuilder.circle( + center: SchemeCoordinates, + scaleRange: FloatRange = defaultScaleRange, + size: Float = 5f, + color: Color = Color.Red, + id: FeatureId? = null, +) = addFeature( + id, SchemeCircleFeature(center, scaleRange, size, color) +) + +fun FeatureBuilder.circle( + centerCoordinates: Pair, + scaleRange: FloatRange = defaultScaleRange, + size: Float = 5f, + color: Color = Color.Red, + id: FeatureId? = null, +) = addFeature( + id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color) +) + +fun FeatureBuilder.custom( + position: Pair, + scaleRange: FloatRange = defaultScaleRange, + id: FeatureId? = null, + drawFeature: DrawScope.() -> Unit, +) = addFeature(id, SchemeDrawFeature(position.toCoordinates(), scaleRange, drawFeature)) + +fun FeatureBuilder.line( + aCoordinates: Pair, + bCoordinates: Pair, + scaleRange: FloatRange = defaultScaleRange, + color: Color = Color.Red, + id: FeatureId? = null, +) = addFeature(id, SchemeLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), scaleRange, color)) + +fun FeatureBuilder.text( + position: SchemeCoordinates, + text: String, + scaleRange: FloatRange = defaultScaleRange, + color: Color = Color.Red, + id: FeatureId? = null, +) = addFeature(id, SchemeTextFeature(position, text, scaleRange, color)) + +fun FeatureBuilder.text( + position: Pair, + text: String, + scaleRange: FloatRange = defaultScaleRange, + color: Color = Color.Red, + id: FeatureId? = null, +) = addFeature(id, SchemeTextFeature(position.toCoordinates(), text, scaleRange, color)) + +@Composable +fun FeatureBuilder.image( + position: Pair, + image: ImageVector, + size: DpSize = DpSize(20.dp, 20.dp), + scaleRange: FloatRange = defaultScaleRange, + id: FeatureId? = null, +) = addFeature(id, SchemeVectorImageFeature(position.toCoordinates(), image, size, scaleRange)) + +fun FeatureBuilder.group( + scaleRange: FloatRange = defaultScaleRange, + id: FeatureId? = null, + builder: FeatureBuilder.() -> Unit, +): FeatureId { + val map = SchemeFeatureBuilder(emptyMap()).apply(builder).build() + val feature = SchemeFeatureGroup(map, scaleRange) + return addFeature(id, feature) +} \ No newline at end of file diff --git a/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeCoordinates.kt b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeCoordinates.kt new file mode 100644 index 0000000..ee9a7db --- /dev/null +++ b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeCoordinates.kt @@ -0,0 +1,34 @@ +package center.sciprog.compose.scheme + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +data class SchemeCoordinates(val x: Float, val y: Float) + +data class SchemeCoordinateBox(val a: SchemeCoordinates, val b: SchemeCoordinates) + +val SchemeCoordinateBox.top get() = max(a.y, b.y) +val SchemeCoordinateBox.bottom get() = min(a.y, b.y) + +val SchemeCoordinateBox.right get() = max(a.x, b.x) +val SchemeCoordinateBox.left get() = min(a.x, b.x) + +val SchemeCoordinateBox.width get() = abs(a.x - b.x) +val SchemeCoordinateBox.height get() = abs(a.y - b.y) + +val SchemeCoordinateBox.center get() = SchemeCoordinates((a.x + b.x) / 2, (a.y + b.y) / 2) + + +fun Collection.wrapAll(): SchemeCoordinateBox? { + if (isEmpty()) return null + val minX = minOf { it.left } + val maxX = maxOf { it.right } + + val minY = minOf { it.bottom } + val maxY = maxOf { it.top } + return SchemeCoordinateBox( + SchemeCoordinates(minX, minY), + SchemeCoordinates(maxX, maxY) + ) +} \ No newline at end of file diff --git a/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeFeature.kt b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeFeature.kt new file mode 100644 index 0000000..8a9e47f --- /dev/null +++ b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeFeature.kt @@ -0,0 +1,121 @@ +package center.sciprog.compose.scheme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import center.sciprog.compose.scheme.SchemeFeature.Companion.defaultScaleRange + +internal typealias FloatRange = ClosedFloatingPointRange + +sealed class SchemeFeature(val scaleRange: FloatRange) { + abstract fun getBoundingBox(scale: Float): SchemeCoordinateBox? + + companion object { + val defaultScaleRange = 0f..Float.MAX_VALUE + const val DEFAULT_RENDERING_ORDER = 0 + const val BACKGROUND_RENDERING_ORDER = 1000 + } +} + + +fun Iterable.computeBoundingBox(scale: Float): SchemeCoordinateBox? = + mapNotNull { it.getBoundingBox(scale) }.wrapAll() + + +internal fun Pair.toCoordinates() = SchemeCoordinates(first.toFloat(), second.toFloat()) + +/** + * A background image that is bound to scheme coordinates and is scaled together with them + * + * @param position the size of background in scheme size units. The screen units to scheme units ratio equals scale. + */ +class SchemeBackgroundFeature( + val position: SchemeCoordinateBox, + val painter: Painter, + scaleRange: FloatRange = defaultScaleRange, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = position +} + +class SchemeFeatureSelector(val selector: (scale: Float) -> SchemeFeature) : SchemeFeature(defaultScaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox? = selector(scale).getBoundingBox(scale) +} + +class SchemeDrawFeature( + val position: SchemeCoordinates, + scaleRange: FloatRange = defaultScaleRange, + val drawFeature: DrawScope.() -> Unit, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position) +} + +class SchemeCircleFeature( + val center: SchemeCoordinates, + scaleRange: FloatRange = defaultScaleRange, + val size: Float = 5f, + val color: Color = Color.Red, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(center, center) +} + +class SchemeLineFeature( + val a: SchemeCoordinates, + val b: SchemeCoordinates, + scaleRange: FloatRange = defaultScaleRange, + val color: Color = Color.Red, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(a, b) +} + +class SchemeTextFeature( + val position: SchemeCoordinates, + val text: String, + scaleRange: FloatRange = defaultScaleRange, + val color: Color = Color.Red, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position) +} + +class SchemeBitmapFeature( + val position: SchemeCoordinates, + val image: ImageBitmap, + val size: IntSize = IntSize(15, 15), + scaleRange: FloatRange = defaultScaleRange, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position) +} + +class SchemeImageFeature( + val position: SchemeCoordinates, + val painter: Painter, + val size: DpSize, + scaleRange: FloatRange = defaultScaleRange, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position) +} + +@Composable +fun SchemeVectorImageFeature( + position: SchemeCoordinates, + image: ImageVector, + size: DpSize = DpSize(20.dp, 20.dp), + scaleRange: FloatRange = defaultScaleRange, +): SchemeImageFeature = SchemeImageFeature(position, rememberVectorPainter(image), size, scaleRange) + +/** + * A group of other features + */ +class SchemeFeatureGroup( + val children: Map, + scaleRange: FloatRange = defaultScaleRange, +) : SchemeFeature(scaleRange) { + override fun getBoundingBox(scale: Float): SchemeCoordinateBox? = + children.values.mapNotNull { it.getBoundingBox(scale) }.wrapAll() +} diff --git a/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeView.kt b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeView.kt new file mode 100644 index 0000000..a6c3e6a --- /dev/null +++ b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeView.kt @@ -0,0 +1,262 @@ +package center.sciprog.compose.scheme + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.* +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import mu.KotlinLogging +import org.jetbrains.skia.Font +import org.jetbrains.skia.Paint +import kotlin.math.max +import kotlin.math.min + + +private fun Color.toPaint(): Paint = Paint().apply { + isAntiAlias = true + color = toArgb() +} + +private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last) + +private val logger = KotlinLogging.logger("SchemeView") + +data class SchemeViewConfig( + val zoomSpeed: Float = 1f / 3f, + val inferViewBoxFromFeatures: Boolean = false, + val onClick: SchemeViewPoint.() -> Unit = {}, + val onViewChange: SchemeViewPoint.() -> Unit = {}, + val onSelect: (SchemeCoordinateBox) -> Unit = {}, + val zoomOnSelect: Boolean = true, +) + +@Composable +public fun SchemeView( + computeViewPoint: (canvasSize: DpSize) -> SchemeViewPoint, + features: Map, + config: SchemeViewConfig = SchemeViewConfig(), + modifier: Modifier = Modifier.fillMaxSize(), +) { + + var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) } + + var viewPointInternal: SchemeViewPoint? by remember { + mutableStateOf(null) + } + + val viewPoint: SchemeViewPoint by derivedStateOf { + viewPointInternal ?: if (config.inferViewBoxFromFeatures) { + features.values.computeBoundingBox(1f)?.let { box -> + val scale = min( + canvasSize.width.value / box.width, + canvasSize.height.value / box.height + ) + SchemeViewPoint(box.center, scale) + } ?: computeViewPoint(canvasSize) + } else { + computeViewPoint(canvasSize) + } + } + + fun DpOffset.toCoordinates(): SchemeCoordinates = SchemeCoordinates( + (x - canvasSize.width / 2).value / viewPoint.scale + viewPoint.focus.x, + (canvasSize.height / 2 - y).value / viewPoint.scale + viewPoint.focus.y + ) + + // Selection rectangle. If null - no selection + var selectRect by remember { mutableStateOf(null) } + + @OptIn(ExperimentalComposeUiApi::class) + val canvasModifier = modifier.pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) + + val event: PointerEvent = awaitPointerEvent() + event.changes.forEach { change -> + if (event.buttons.isPrimaryPressed) { + //Evaluating selection frame + if (event.keyboardModifiers.isShiftPressed) { + selectRect = Rect(change.position, change.position) + drag(change.id) { dragChange -> + selectRect?.let { rect -> + val offset = dragChange.position + selectRect = Rect( + min(offset.x, rect.left), + min(offset.y, rect.top), + max(offset.x, rect.right), + max(offset.y, rect.bottom) + ) + } + } + selectRect?.let { rect -> + //Use selection override if it is defined + val box = SchemeCoordinateBox( + rect.topLeft.toDpOffset().toCoordinates(), + rect.bottomRight.toDpOffset().toCoordinates() + ) + config.onSelect(box) + if (config.zoomOnSelect) { + val newScale = min( + canvasSize.width.value / box.width, + canvasSize.height.value / box.height + ) + + val newViewPoint = SchemeViewPoint(box.center, newScale) + + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + } + selectRect = null + } + } else { + val dragStart = change.position + val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) + config.onClick(SchemeViewPoint(dpPos.toCoordinates(), viewPoint.scale)) + drag(change.id) { dragChange -> + val dragAmount = dragChange.position - dragChange.previousPosition + val newViewPoint = viewPoint.move( + -dragAmount.x.toDp().value / viewPoint.scale, + dragAmount.y.toDp().value / viewPoint.scale + ) + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + } + } + } + } + } + } + }.onPointerEvent(PointerEventType.Scroll) { + val change = it.changes.first() + val (xPos, yPos) = change.position + //compute invariant point of translation + val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates() + val newViewPoint = viewPoint.zoom(-change.scrollDelta.y * config.zoomSpeed, invariant) + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + }.fillMaxSize() + + Canvas(canvasModifier) { + fun SchemeCoordinates.toOffset(): Offset = Offset( + (canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.scale).toPx(), + (canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.scale).toPx() + ) + + + fun DrawScope.drawFeature(scale: Float, feature: SchemeFeature) { + when (feature) { + is SchemeBackgroundFeature -> { + val offset = SchemeCoordinates(feature.position.left, feature.position.top).toOffset() + + val backgroundSize = DpSize( + (feature.position.width * scale).dp, + (feature.position.height * scale).dp + ).toSize() + + translate(offset.x, offset.y) { + with(feature.painter) { + draw(backgroundSize) + } + } + } + is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale)) + is SchemeCircleFeature -> drawCircle( + feature.color, + feature.size, + center = feature.center.toOffset() + ) + is SchemeLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) + is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset()) + is SchemeImageFeature -> { + val offset = feature.position.toOffset() + val imageSize = feature.size.toSize() + translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) { + with(feature.painter) { + draw(imageSize) + } + } + } + is SchemeTextFeature -> drawIntoCanvas { canvas -> + val offset = feature.position.toOffset() + canvas.nativeCanvas.drawString( + feature.text, + offset.x + 5, + offset.y - 5, + Font().apply { size = 16f }, + feature.color.toPaint() + ) + } + is SchemeDrawFeature -> { + val offset = feature.position.toOffset() + translate(offset.x, offset.y) { + feature.drawFeature(this) + } + } + is SchemeFeatureGroup -> { + feature.children.values.forEach { + drawFeature(scale, it) + } + } + } + } + + if (canvasSize != size.toDpSize()) { + canvasSize = size.toDpSize() + logger.debug { "Recalculate canvas. Size: $size" } + } + clipRect { + features.values.filterIsInstance().forEach { background -> + drawFeature(viewPoint.scale, background) + } + features.values.filter { + it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange + }.forEach { feature -> + drawFeature(viewPoint.scale, feature) + } + } + selectRect?.let { rect -> + drawRect( + color = Color.Blue, + topLeft = rect.topLeft, + size = rect.size, + alpha = 0.5f, + style = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + ) + } + } +} + +@Composable +fun SchemeView( + initialViewPoint: SchemeViewPoint, + features: Map = emptyMap(), + config: SchemeViewConfig = SchemeViewConfig(), + modifier: Modifier = Modifier.fillMaxSize(), + buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {}, +) { + val featuresBuilder = SchemeFeatureBuilder(features) + featuresBuilder.buildFeatures() + SchemeView( + { initialViewPoint }, + featuresBuilder.build(), + config, + modifier + ) +} diff --git a/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeViewPoint.kt b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeViewPoint.kt new file mode 100644 index 0000000..412b558 --- /dev/null +++ b/scheme-kt/src/jvmMain/kotlin/center/sciprog/compose/scheme/SchemeViewPoint.kt @@ -0,0 +1,23 @@ +package center.sciprog.compose.scheme + +import kotlin.math.pow + +data class SchemeViewPoint(val focus: SchemeCoordinates, val scale: Float = 1f) + +fun SchemeViewPoint.move(deltaX: Float, deltaY: Float): SchemeViewPoint { + return copy(focus = SchemeCoordinates(focus.x + deltaX, focus.y + deltaY)) +} + +fun SchemeViewPoint.zoom( + zoom: Float, + invariant: SchemeCoordinates = focus, +): SchemeViewPoint = if (invariant == focus) { + copy(scale = scale * 2f.pow(zoom)) +} else { + val difScale = (1 - 2f.pow(-zoom)) + val newCenter = SchemeCoordinates( + focus.x + (invariant.x - focus.x) * difScale, + focus.y + (invariant.y - focus.y) * difScale + ) + SchemeViewPoint(newCenter, scale * 2f.pow(zoom)) +} \ No newline at end of file diff --git a/scheme-kt/src/jvmMain/resources/middle-earth.jpg b/scheme-kt/src/jvmMain/resources/middle-earth.jpg new file mode 100644 index 0000000..4ed4735 Binary files /dev/null and b/scheme-kt/src/jvmMain/resources/middle-earth.jpg differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 18fc3d1..a321853 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,8 @@ pluginManagement { include( ":maps-kt-core", ":maps-kt-compose", - ":demo" + ":demo:maps", + ":scheme-kt", + ":demo:scheme" )