High level flow of the library #7
heckj
started this conversation in
Development
Replies: 1 comment 1 reply
-
I created #8 to capture stubbing out the structure roughly outlined above, and seeing what that illuminates in terms of additional constraints or patterns |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
At the top of mind is the question "What are the major components for this library, and what do they need to do?"
I have a baseline of what I'd like the developer experience to be like when creating a chart:
Chart
I know that I want
Chart
to returnsome View
so that it seamless fits in with SwiftUI's declarative structure. Chart itself has minimal configuration — margins, potentiallycaption
— with most of the visual display being defined by what is inside Chart. Chart pretty clear aligns with SwiftUI's concept of a LayoutContainerMy initial thinking is that a general
title
for the chart might be more conveniently handled by composingText()
containing the title with theChart()
when desired. Likewise, to explicitly set the size of the chart, it makes sense to leverage theframe
viewModifier. So the two potential modifiers I can see to use onChart
aremargin
andcaption
.SwiftUI already have a concept of a chart's margin as EdgeInsets that seem to make sense to leverage. They let you provide different inset values on each side, and there are existing modifiers (for example, padding) that use this structure. I suspect we can't "just use"
.padding()
in this case - because the defaultmargin
space is where an axis, ticks, and labels for that axis are displayed. Whether or not an axis is displayed, and the size of its visible content, will influence themargin
area if not explicitly defined.Marks
The pieces within
Chart
might be, but don't need to be, views as well. The code snippet implies that a chart is made up of one of more "Marks", which matches with the broader pattern. Likewise, we know we're going to have multiple kinds of marks, all of which ultimately provide the same kinds of results: one or more shape elements - the positions, color, size, shape, etc derived and mapped from either external data that the developer provides, or constants in a declaration - that get rendered onto a canvas.The configuration of the marks also defines a bit more about the overall chart layout - all of the marks have at some sense of an
x
andy
coordinate space scale - and for one, or more, of those scales we might want to draw an axis, ticks, etc - with label values derived from the data within the marks. I can already see a potential conflict in that a developer could specify three marks, both withx
axis, and add whatever declaration to have them as displayed: Since there's only two sides that would be appropriate to show anx
scale (top
andbottom
edges), something is going to get weird. I think that might be an acceptable "You did this to yourself!" scenario, but we might optionally want to check for it - perhaps kicking a preconditionFailure if that occurs.In any case the Chart object is likely the coordinator, or holder, of the broader configuration since it sits "above" the marks. As such, it should to collect the results of the marks, map out what additional configuration elements (such as
axis
andlegend
) they provide, and be responsible for the layout or positioning choices that allow for these elements to have space.There is likely going to be common methods and attributes of the marks, which potentially imply the use of generics, but for a starting point I think it makes more sense to treat each mark individually, see how they lay out in initial code, and refactor the common parts together after we have at least one implemented, and start to work on a second or third.
It might also make sense that each
Mark
results in aView
that is layered on top of each other, in which case theChart
may be acting like aZStack
container view - dropping in the views generated from each mark. There might also be some side benefits from eachMark
instance declared being its own "view" in that we might get some leverage from the SwiftUI framework caching the views that don't change and re-using them, especially when data changes only impact one mark and multiple are declared in a chart.VisualChannel
A Mark itself is constructed from one (maybe two) or more visual channels - so it's pretty clear that
VisualChannel
here isn't going to be a view, but something else thatMark
uses through a result builder style declaration. Each mark is going to have a number of default, and probably one or two required, visual channels. The required channels will need to be verified, and anything left undeclared will pick up defaults. I expect defining the potential channels will be somewhat iterative, taking inspiration from other implementations, and starting to document those channels, and their defaults in the technote Marks.Scales
In the hypothetical example above, I have an explicit scale declaration described as a modifier that's currently on a
VisualChannel
:.logScale(0.1, 100)
. The default scaling for a visual channel should be able to be derived from the data the developer provides - with a default of a linear scale and the domain min and max being the min and max of the data mapped to the visual channel. As a modifier, I think this means that we can an optionally defined scale and only generate a default when the scale isn't declared.From earlier experiments with scale, I found I didn't always know the range values when I was creating the scale, so the initial implementation work on scales takes the range that it maps to as function parameters, with only the domain - and some additional scale configuration parameters - during initialization. With multiple scales, I set up
Scale
as a protocol with associated types with Linear, Log, and Power scales as generic types that conform to the Scale protocol. In my initial implementation, I created multiple different scales - one each for mapping Double, Float, and Int, and collected them into an enumeration. All of this earlier work is collected in SwiftViz package, with documentation available for that package.Note: None of the prior work needs is necessarily correct or complete, and we may find we want to change implementations, or whole structural elements, based on the development in this project.
Axis
An axis is defined out of a few specific visual channels from a mark, based on the scale of that visual channel. There needs to be a modifier to define that it should be displayed, but where the modified it applied is a bit tricker. I think it needs to be at least on VisualChannel within a Mark (if not higher in the hierarchy) so that a default scale could be marked as displayed, and the appropriate edge for the displayed axis defined. This also implies the modifier shouldn't be available on all visual channels, but only a couple of them - visual channels that map
x
and ory
coordinates.One path would be to make the modifier specific to the axis and embed it into the name, for example:
.xAxis()
and.yAxis()
. With only two potential axis that doesn't seem too bad. The axis should allow for specifying the position where the axis is display - one of the edges, which will be specific to thex
ory
variant. The.xAxis()
then would take an optional edge of either.top
or.bottom
, using a default of.bottom
if not defined.Another path would be to not bake the direction into the name, but infer it based on the direction of the visual channel that it's applied.
There's a notable amount of additional configuration on a displayed axis that we might want to allow to be declared:
x
andy
with rules, you get a chart grid)All of these can be inferred or defaulted, but the one that stands out to be as potentially being something to put in the
.axis()
modifier initializer is either the number of ticks to draw, or an array of explicit tick values if the developer wants to declare those.If we define the rest as an "axis" configuration struct with appropriate defaults, we could use either initializers or modifiers on
axis
itself to potentially update it. This may also be the case where the values are something we want to apply to both axis, and enabling that implies "pushing" the values up the stack to a higher object (possibly the chart), allowing for a chart-wide setting, or even further - using a background environment variables for the same. This feels like an area that needs a bit more exploration, possibly after some basics are in place.Mapping the Data through Channels into Visual Properties
The initial swag at the design has
data
of some form provided toMark
in its initializer. I think, but don't know, if this is the correct level - I can see a potential use case for data to be provided to Chart and it to deal with the mapping from there based on the Mark declarations, which would also allow multiple marks to implicitly use the same data, or if you wanted to map different properties from the data into different marks within a chart.The future-scoped idea of faceting or stacking multiple charts together from a single data source may also impact where data needs to be defined. Faceting being looking at the same data from two different categories derived from that data.
Where-ever the data is defined and introduced, the mapping itself is in this concept of a visual channel as a part of a Mark. I have so far been assuming that data structured as an array of something. A
bar
mark, for example, could work on data as simple as an array ofInt
orDouble
, inferring the category (and position) for the bars from the index location within the array. That wouldn't work fordot
, which requires quantitative data for bothx
andy
coordinates to plot each point, but the obvious simplest version of that is an array of tuples:(Double, Double)
(or variations on that theme - it could even be(Double, Int)
or(Int, Int)
). The more complex versions would be an array of structs or classes, and pulling the data from properties on those objects. In those cases, using something like the KeyPath as described in the example at the top, is compelling.One aspect I'm not super familiar with is how a key path might be able to be used in an initializer. In particular, I think the Swift compiler will want the specific object that it's referencing to be defined as an aspect of the
VisualChannel
, so the specific type we'd use to define a key path like this is either KeyPath or PartialKeyPath, both of which are generic classes overRoot
, the type of which would be inferred by the type of the data provided.The type of visual property channel is defining will inform what kinds of data are possible inputs. This is a place where we can leverage the Swift language and our rough mappings of quantitative data being
Double
, ordinal data beingInt
, categorical data beingString
(and temporal data asDate
orDouble
). We'll want to constrain the inputs mappings to just those kind of inputs to take advantage of the Swift compiler to enforce that a developer doesn't attempt to blindly map anUIImage
into anx
coordinate.Since VisualChannel is going to need to be a generic, its use within Mark will need to use it as an existential type, opaque type, or Mark will need to be generic itself over the data in order to coordinate using the correct type information for VisualChannel. Of the options, opaque types or explicit generic types would seem to make the most sense. This is an area where I don't do well abstractly reasoning about what's needed and the constraints, so to step further I suspect will require an initial implementation to vet and enforce the compiler's constraints for me.
A Note on Temporal Data
How we map temporal data is a bit undetermined right now, with a
Date
construct getting added to the swift standard library (was previously defined in Foundation) with Swift 5.7. I think maybe starting with theDate
class in Foundation for now would make the most sense, as one of the tricky bits about temporal data is that things like tick intervals, and "nice" tick intervals, are often more meaningfully defined in terms of calendrical elements: minutes, hours, days, weeks, and so on.Whatever choices we make should to propagate down to the Scale implementation, which doesn't currently have a Date oriented scale.
Beta Was this translation helpful? Give feedback.
All reactions