Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tick Marks layout #8

Merged
merged 30 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f1b69e4
Add data min and max evaluation to `Plot`
danielost Jun 25, 2023
e6f86e7
Merge branch 'main' of https://github.com/danielost/chartreuse
danielost Jun 29, 2023
6f46d75
Merge branch 'creativescala:main' into main
danielost Jul 2, 2023
39625b5
Add tick marks layout
danielost Jul 3, 2023
6bcc79f
Add major ticks layout
danielost Jul 3, 2023
760efa2
Add grid layout
danielost Jul 4, 2023
d16691c
Replace `interpolatingSpline` with `OpenPath` and `ClosedPath`
danielost Jul 4, 2023
ef71598
Refactor `Plot`
danielost Jul 5, 2023
c3c79b8
Add `TicksSequence` type to `Plot`
danielost Jul 5, 2023
5fef71d
Add comments to `Plot`
danielost Jul 5, 2023
12b42b3
Add return types to plot methods
danielost Jul 6, 2023
f2ca421
Fix bounding box calculation in `Plot`
danielost Jul 7, 2023
bff47ef
Replace `return` with an expression
danielost Jul 7, 2023
18ec3a5
Remove duplicate code in `Plot`
danielost Jul 7, 2023
634ceb0
Refactor tuple member access
danielost Jul 7, 2023
08963ff
Move `Alg` constraint to `draw`
danielost Jul 8, 2023
2c7c02c
Update documentation for `ticksToSequence`
danielost Jul 8, 2023
f8adeb4
Add opaque types for screen and data coordinates
danielost Jul 8, 2023
a8c2778
Remove duplicate extension from `Coordinate`
danielost Jul 9, 2023
689f21d
Fix formatting
danielost Jul 9, 2023
a82130d
Fix mistake in bounding box calculation
danielost Jul 10, 2023
c4b4c1a
Remove wrapper object for opaque types
danielost Jul 10, 2023
06110c4
Add `boundingBox` method to `Layer`
danielost Jul 10, 2023
e3626d9
Refactor builder methods in `Plot`
danielost Jul 11, 2023
937453a
Replace magic numbers with named values
danielost Jul 11, 2023
679ac97
Use `NumberFormat` instead of manual rounding
danielost Jul 11, 2023
eb731e5
Remove `A` type parameter from `Plot`
danielost Jul 11, 2023
cb73dd3
Update `PlotExample`
danielost Jul 11, 2023
7132d28
Use `Intl.NumberFormat` instead of `java.text.NumberFormat` for Scala.js
danielost Jul 11, 2023
482d74e
Fix typos in `NumberFormat`
danielost Jul 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions core/js/src/main/scala/chartreuse/NumberFormat.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright 2015 Creative Scala
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package chartreuse

import scala.scalajs.js
import scala.scalajs.js.annotation.*

@js.native
@JSGlobal("Intl.NumberFormat")
class NumberFormat extends js.Object {
def format(value: Double): String = js.native
}
243 changes: 243 additions & 0 deletions core/js/src/main/scala/chartreuse/Plot.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
* Copyright 2015 Creative Scala
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package chartreuse

import doodle.algebra.*
import doodle.core.*
import doodle.syntax.all.*

/** A `Plot` is a collection of layers along with a title, legend, axes, and
* grid.
*/
final case class Plot[Alg <: Algebra](
layers: List[Layer[?, Alg]],
plotTitle: String = "Plot Title",
xTitle: String = "X data",
yTitle: String = "Y data",
grid: Boolean = false,
tickSize: Int = 7
) {
type TicksSequence = Seq[(ScreenCoordinate, DataCoordinate)]
type PlotPicture = Picture[
Alg & Layout & Text & Path & Style & Shape & doodle.algebra.Transform,
Unit
]

private val margin = 10

def addLayer(layer: Layer[?, Alg]): Plot[Alg] = {
copy(layers = layer :: layers)
}

def withPlotTitle(newPlotTitle: String): Plot[Alg] = {
copy(plotTitle = newPlotTitle)
}

def withXTitle(newXTitle: String): Plot[Alg] = {
copy(xTitle = newXTitle)
}

def withYTitle(newYTitle: String): Plot[Alg] = {
copy(yTitle = newYTitle)
}

def withGrid(newGrid: Boolean): Plot[Alg] = {
copy(grid = newGrid)
}

def draw(width: Int, height: Int): PlotPicture = {
val dataBoundingBox = layers.foldLeft(BoundingBox.empty) { (bb, layer) =>
bb.on(layer.boundingBox)
}

val minX = dataBoundingBox.left
val maxX = dataBoundingBox.right
val minY = dataBoundingBox.bottom
val maxY = dataBoundingBox.top

val scale = Scale.linear.build(dataBoundingBox, width, height)

val xTicks = TickMarkCalculator.calculateTickScale(minX, maxX, 12)
val yTicks = TickMarkCalculator.calculateTickScale(minY, maxY, 12)

// Map the Ticks to the screen coordinates
val xTicksMapped = Ticks(
scale(Point(xTicks.min, 0)).x,
scale(Point(xTicks.max, 0)).x,
xTicks.size
)
val yTicksMapped = Ticks(
scale(Point(0, yTicks.min)).y,
scale(Point(0, yTicks.max)).y,
yTicks.size
)

// Convert the Ticks to a sequence of points
val asX: Double => Point = x => Point(x, 0)
val asY: Double => Point = y => Point(0, y)
val xTicksSequence = ticksToSequence(xTicks, scale, asX)
val yTicksSequence = ticksToSequence(yTicks, scale, asY)

val numberFormat = new NumberFormat

val allLayers =
layers
.map(_.draw(width, height))
.foldLeft(empty[Alg & Layout & Shape])(_ on _)

val createXTick: ScreenCoordinate => OpenPath =
screenCoordinate =>
OpenPath.empty
.moveTo(screenCoordinate.x, yTicksMapped.min - margin)
.lineTo(screenCoordinate.x, yTicksMapped.min - margin - tickSize)

val createXTickLabel: (ScreenCoordinate, DataCoordinate) => PlotPicture =
(screenCoordinate, dataCoordinate) =>
text(numberFormat.format(dataCoordinate.x))
.at(screenCoordinate.x, yTicksMapped.min - 30)

val createYTick: ScreenCoordinate => OpenPath =
screenCoordinate =>
OpenPath.empty
.moveTo(xTicksMapped.min - margin, screenCoordinate.y)
.lineTo(xTicksMapped.min - margin - tickSize, screenCoordinate.y)

val createYTickLabel: (ScreenCoordinate, DataCoordinate) => PlotPicture =
(screenCoordinate, dataCoordinate) =>
text(numberFormat.format(dataCoordinate.y))
.at(xTicksMapped.min - 45, screenCoordinate.y)

val plotTitle = text(this.plotTitle)
.scale(2, 2)
val xTitle = text(this.xTitle)
val yTitle = text(this.yTitle)
.rotate(Angle(1.5708))

yTitle
.beside(
allLayers
.on(withTicks(xTicksSequence, createXTick, createXTickLabel))
.on(withTicks(yTicksSequence, createYTick, createYTickLabel))
.on(withAxes(xTicksMapped, yTicksMapped))
.on(
if grid then
withGrid(
xTicksMapped,
yTicksMapped,
xTicksSequence,
yTicksSequence
)
else empty[Shape]
)
.margin(5)
.below(plotTitle)
.above(xTitle)
)

}

/** Converts `Ticks` to a list of tuples. The first element is the mapped
* coordinate of a tick, i.e. a screen coordinate - to place the tick on a
* graph. The second one is the original coordinate, i.e. a data coordinate -
* to give the tick a label with its coordinate. Screen coordinates are the
* coordinates of the graph rendered on the screen. Data coordinates are the
* values in the data.
*/
private def ticksToSequence(
ticks: Ticks,
scale: Bijection[Point, Point],
toPoint: Double => Point
): TicksSequence = {
(0 to ((ticks.max - ticks.min) / ticks.size).toInt)
.map(i =>
(
ScreenCoordinate(scale(toPoint(ticks.min + i * ticks.size))),
DataCoordinate(toPoint(ticks.min + i * ticks.size))
)
)
.toList
}

private def withTicks(
ticksSequence: TicksSequence,
createTick: ScreenCoordinate => OpenPath,
createTickLabel: (ScreenCoordinate, DataCoordinate) => PlotPicture
): PlotPicture = {
ticksSequence
.foldLeft(
empty[
Alg & Layout & Text & Path & Style & Shape & doodle.algebra.Transform
]
)((plot, tick) =>
val (screenCoordinate, dataCoordinate) = tick

plot
.on(createTick(screenCoordinate).path)
.on(createTickLabel(screenCoordinate, dataCoordinate))
)
}

private def withAxes(
xTicksMapped: Ticks,
yTicksMapped: Ticks
): PlotPicture = {
ClosedPath.empty
.moveTo(xTicksMapped.min - margin, yTicksMapped.min - margin)
.lineTo(xTicksMapped.max + margin, yTicksMapped.min - margin)
.lineTo(xTicksMapped.max + margin, yTicksMapped.max + margin)
.lineTo(xTicksMapped.min - margin, yTicksMapped.max + margin)
.path
}

private def withGrid(
xTicksMapped: Ticks,
yTicksMapped: Ticks,
xTicksSequence: TicksSequence,
yTicksSequence: TicksSequence
): PlotPicture = {
xTicksSequence
.foldLeft(empty[Layout & Path & Style & Shape])((plot, tick) =>
val (screenCoordinate, _) = tick

plot
.on(
OpenPath.empty
.moveTo(screenCoordinate.x, yTicksMapped.min - margin)
.lineTo(screenCoordinate.x, yTicksMapped.max + margin)
.path
.strokeColor(Color.gray)
.strokeWidth(0.5)
)
)
.on(
yTicksSequence
.foldLeft(empty[Layout & Path & Style & Shape])((plot, tick) =>
val (screenCoordinate, _) = tick

plot
.on(
OpenPath.empty
.moveTo(xTicksMapped.min - margin, screenCoordinate.y)
.lineTo(xTicksMapped.max + margin, screenCoordinate.y)
.path
.strokeColor(Color.gray)
.strokeWidth(0.5)
)
)
)
}
}
Loading