Versions

Changelog

0.5.2Patch

[#12](https://github.com/hzblj/zyplot/pull/12) [7622700](https://github.com/hzblj/zyplot/commit/762270062c4342acebfffddba806fb30d2b3ff1d) Thanks [@hzblj](https://github.com/hzblj)! - Build the Android module with expo-module-gradle-plugin instead of the legacy ExpoModulesCorePlugin.gradle helpers, so it stops crashing on startup under Expo SDK 57 with UnsupportedOperationException: This function has a reified type parameter. From SDK 57 on, the Compose View and Prop definitions inline calls into the Pika compiler plugin, which only the module plugin applies; compiled without it, those calls survive as stubs that throw the moment the module registers. The Compose compiler now also follows the host project's Kotlin version rather than a pinned one. Thanks [@divineniiquaye](https://github.com/divineniiquaye) for the report and the diagnosis.

0.5.1Patch

Restore the package README to its pre-rewrite form, so the npm page shows the badges, social card, and docs link again instead of the rewritten copy with a stray + in the heading.

03fd235
0.5.0MinorPatch

Give measuring a mark its own name, instead of a point annotation dressed down to nothing.

Lining a view of your own up with the data — a grid, a row of labels, a box around a stretch — already worked: an annotation with hidden lands where its data lands and reports back through geometry, on all three renderers. But asking for one meant writing annotation.point({color, zero.

annotation.measure({id, x, y}) is that, spelled as what it is. Nothing else about it is adjustable, because a measurement has no appearance to set.

The docs now also say why you would reach for it over geometry.plot: the plot rect is the box around the marks, and each renderer puts the axis padding on its own side of that box, so a grid placed off plot alone is a few points out on one of the three. A measured mark is exact everywhere.

10d8276

An interaction says what is being read, and never where the finger is.

ChartScrubSelection loses nativeX and nativeY, ChartInteractionRange loses startX and endX, and the interaction event loses the pair with them. Nothing reports where a reading is any more.

The reason is the thing they were used for. A reading moves many times a mark, and a position that crossed out to be laid out again could only ever land a render after the crosshair it belongs beside — so a card following a finger trailed it, and trailed it further the more the screen had to re-render to move it. Every one of them is a tooltip or a rangeView instead: the chart mounts the node and moves it in the same pass it draws the reading in, which is what those slots were added for.

geometry stays, and stays the way down for a view that is laid against the plot rather than against a finger — a grid behind the marks, a row of labels under them, a button on a rule. It is a layout report: it arrives when the chart measures itself and moves when the chart does.

Two things follow from taking the position out:

- A scrub within one mark now changes nothing. useChartScrub compared the position as well as the index, so a finger travelling across a single mark produced a new selection on every touch the platform reported, and every screen reading it re-rendered for a reading that had not changed. - On the web, a form with no scrub layer reports no position on a hover or a click either. It never reported a mark for one to belong to, so a view placed from it had somewhere to sit and nothing to say.

To migrate, name the view instead of positioning it: tooltip.above({view}) for a chip over the rule, tooltip.beside({view}) for a card at the reading, rangeView for one over a two-finger span. What each shows is read from your own context rather than passed in, so a reading changes what a view says without changing a prop on the chart — which is the other half of why this is faster.

10d8276

Let a slot be a component, and put it where the thing it stands in for is.

ChartSlotView is ReactNode | ComponentType: every slot — the reading's view, the span's, an annotation's — takes a component as well as an element, and a component is rendered with no props of its own. That is what puts the app's own views in a zyplot config. An element is a new value on every render, so a config holding one is rebuilt on every render with it; a component reference is the same value for the life of the module, so the config is too. What the view shows comes from its own hooks rather than from a prop threaded through the chart, which means a reading changes the chip and nothing else: the chart's props are untouched, memo holds, and on iOS and Android the dataset is not serialised again for a finger that moved.

Both slots now sit with what they belong to rather than beside it. tooltipAnchor and tooltipView are one tooltip prop, built with tooltip.above({lift, view}) or tooltip.beside({gap, view}) — one thing to pass, and the placement still decides which fields exist. An annotation's view is written as view on the annotation itself, and zyplot lifts it into annotationViews keyed by that annotation's id, the way it already lifts a series' style into seriesStyles: a record whose keys repeat an id declared elsewhere is now built rather than written.

The example app is the reference for both. Its reading chips and its quote card are components named in a config, each subscribing to a context the screen provides, and the Revolut event badge rides on the annotation it marks.

One name for the reading, and one place to set it. interaction.tooltip is gone: tooltip is the whole answer — left out or true the chart writes its own card, false draws nothing, and a tooltip.above({view}) hands over yours. So there is no longer a boolean two levels down that a view has to agree with, and interaction.scrub() no longer reaches out of its own group to switch a card off — a chart that wants none says tooltip: false where it says everything else. The bridge is untouched: what crosses it is still interaction.tooltip and tooltipAnchor, resolved from the one prop on the way out.

10d8276

Say where a view of yours sits in the box the chart lays it in, instead of taking the one place the chart picked.

ChartViewAlign is 'top' | 'center' | 'bottom', and two places read it.

An annotationViews entry can now be {align, view} as well as the view itself. What the three mean follows from how the mark runs: a rule down the plot is a mark with a height of its own, so they are its head, its middle and its foot — and its head is still what a view gets unasked, because that is where the chart's own badge goes. A point and a rule across the plot are spots rather than runs, so they read as on the mark, above it and below it, and 'center' is what those get unasked.

tsx annotationViews={{ earnings: {align: 'center', view: EarningsChip}, live: {align: 'top', view: LiveBadge}, }}

tooltip.beside takes the same word for the card it places. That card sat against the plot's top edge whatever it was, which is right for one read as belonging to the reading and wrong for one big enough to want the middle of the plot:

tsx tooltip.beside({ align: "center", view: ReadingCard });

tooltip.above does not take it: a chip placed above is already lifted clear of the plot, so it has no room to be placed down, and the builder rejects the field rather than ignoring it.

Nothing moves that did not ask to — every default is what the place did before. One exception, and it is a fix: a view on a rule down the plot straddled the plot's top edge on the web, with half of it above the chart, where iOS and Android sat it below the edge. All three now do what the two did.

10d8276

Put your own views inside the chart, and let the chart place them.

tooltip takes your own view and mounts it in the plot, moving it with the reading itself. tooltip.beside({view}) sets it next to the finger and flips it at the plot's edge, which is what a card of rows wants; tooltip.above({view}) centres it on the reading and lifts it clear of the plot, which is where the rule's own chip goes. An annotation's view mounts the same way, so a badge on an annotation is placed by the chart rather than by a render.

A rule's view is laid along the rule rather than centred on a point: a line annotation's spot is where it starts, so a view for one runs from the plot's edge and is centred only across the rule. Centring it would hang half of it outside the plot, which is what a point's view wants and a rule's never does. Size it from geometry.plot, which arrives on layout rather than on every step of the finger.

What the chart gives up for a view depends on what the view can stand in for. A point _is_ its mark, so one with a view is not drawn at all. A rule is not: the view caps it the way the chart's own badge would, so the rule stays and the badge and label it would have worn come off — hiding the rule as well would take the line out from under the head the app just put on it.

The point is what it costs, which is nothing. A view placed from a scrub handler crosses into JavaScript and back before it moves, so on a fast drag it lands well behind the crosshair it belongs to. These are moved where the crosshair is moved — in the chart's own layout pass, on iOS and Android — so JavaScript never sees the position at all. What is inside the view is still yours to render from useChartScrub, and that part arrives when React gets to it.

rangeView does the same for the span under two fingers, centred on it and lifted clear of the plot. The chart writes nothing for a span of its own, so that one adds rather than replaces.

The chart drops whatever the view stands in for: one placed 'above' replaces the rule's label, one beside the reading replaces the card. Nothing else the app asked for changes.

crosshairStyle loses labelBackground, labelColor, labelLift, labelPadding, labelRadius and labelSize. The label is the theme's own label colour at the size the axes use, and anything past that is a view now — describing a pill twice was the thing worth removing.

10d8276

Discriminate the interaction event by its phase, so the field a phase is about stops being optional.

ChartInteractionEvent was thirteen optional fields, phase included, which left every reader checking for something the chart had always sent: a 'layout' event has a geometry, a reading has an index, a two-finger one has a range. Now testing the phase narrows to the variant that carries it, and the rest of the fields stay readable without narrowing, since a form fills in what it can. The one path with no phase — a click or a hover on a form with no scrub layer — is a variant of its own rather than a gap in the middle of the others.

Nothing changes at runtime; the events are the ones the renderers were already sending.

10d8276

Give the reading under a finger a preset, and the handler a name to be held in.

interaction.scrub() returns the shape a scrub almost always takes — an x crosshair, haptics, the nearest mark rather than the axis slice, and no built-in tooltip — with anything you pass winning over it. Every chart in the examples had been writing those four out by hand, which is four chances to leave one off and no signal when you do.

ChartInteractionHandler names what onInteraction takes, so an app declaring its own handler no longer reaches for Parameters<typeof Chart.Line>[0]['onInteraction'] to spell it.

10d8276

Describe a whole chart as one object.

zyplot(z => ({…})) calls the builder with every factory the package exports as z and returns the props a chart takes, so its data, its axes, its arrival and the styling of each series are one expression behind one import rather than a page of props assembled from six of them. That is what makes a sample liftable: the object is the chart, so it copies into an app whole. It is on all four entry points, and ZyplotFactories and ZyplotChartProps name what goes in and what comes out.

The style declared on a series is split into seriesStyles on the way out, so zyplot is seriesProps as well — the id is written once, and an explicit seriesStyles entry still wins over the style on the series it names. Nothing has to be in the object: one that leaves the data out is a preset, and the props it is spread under fill in the rest.

Naming the form checks the config where it is written rather than where it is spread: zyplot<LineChartProps>(…) for a whole chart, zyplot<Partial<LineChartProps>>(…) for a preset that expects its data at the call site.

Every chart in the example app is built this way now, the five design studies included, so each one is a config to read and the JSX beside it is a spread. Only the app's own nodes — the tooltip view, the annotation views — are still passed as props, since a React element is new on every render and folding one into the config would rebuild the dataset with it.

10d8276

Take out the options no renderer ever drew, rather than keep promising them.

interaction.pan was decoded on both native platforms and read by neither, and the docs said so in as many words. ChartPointAnnotation.symbol typechecked everywhere and changed nothing anywhere — the web draws a fixed dot and neither native side has the field at all. xAxis.scrollPosition was advertised as an iOS extra, and the scrollable-axis modifier only ever read visibleDomain beside it. An option that compiles and does nothing is worse than one that is missing: it reads as a setting that did not take.

Gone with them are the names nothing referenced — NATIVE_CHART_KINDS, NativeChartKind, NativeChartPropsByKind, NativeChartConfiguration, ChartExtensionKindIos and ChartExtensionKindAndroid. ChartSeriesStyle.symbol stays; that one is drawn.

10d8276

Narrow the web props to the ones each form actually answers.

ChartBaseProps carried annotations, interaction, onInteraction, plot, seriesStyles, xAxis, yAxis and the overlay slots onto all twenty-one forms, and five of them read those: line, area, bar, stacked bar and candlestick. On the other sixteen they typechecked and vanished — an interaction handed to Chart.Pie compiled, ran, and did nothing, which reads as a setting that did not take rather than as one the form has no use for.

The base now splits three ways. ChartBaseProps is what every form takes; ChartAxesProps adds the axis switch for the forms that draw a pair; ChartPlotProps and ChartSeriesPlotProps add the plot, the axes options, the annotations and the scrub slots for the forms with a layer that reads the pointer. The native props are untouched — the native renderers do read these on every form, so a .ios.tsx or .android.tsx file keeps the wider surface.

ChartInteractionEvent loses timestamp, x and y in the same pass: no renderer on any platform ever filled them in.

10d8276

Export the types the props already had you holding, and stop shipping one name for two shapes.

ChartTooltipAnchor, ChartTooltipPlacement, ChartInteractionRange, ChartRangeStyle and ChartBandAlign were all reachable through an exported type and none of them could be imported, so a web app could hold one of these values but never annotate it. Chart.Provider on @hzblj/zyplot/ios and @hzblj/zyplot/android now exports its ChartProviderProps as well.

ChartColorMode meant two different unions depending on the entry point — the core one on native, and a web one that added 'inherit' for the provider. The web entry now exports the core union under that name and the wider one as ChartProviderColorMode, so the same import means the same thing everywhere. ChartOrientation likewise replaces the five inline copies of 'horizontal' | 'vertical'.

10d8276

Close a web reading when the finger lifts, rather than waiting for a pointer that never leaves.

Everything a reading puts up comes down on zrender's globalout: the tooltip, the axis pointer, the crosshair and marker the scrub layer draws, and the step back on the rest of the trace. Under a mouse that event arrives the moment the pointer leaves the plot. Under a finger it never arrives at all — zrender turns touchend into a mouseup and nothing behind it, and does not listen for touchcancel in the first place. So a reading taken by touch stayed up after the touch was over, and onInteraction never reported the ended phase that closes a readout of your own.

The leave the browser declines to send is now raised on the chart itself, which is the one event all of them are already watching, so one line closes the lot. It goes up a frame after the touch rather than inside it: a touch under the click delay makes zrender fire a synthetic click of its own, and a leave landing before that would only be undone by it.

Chart.TimeSeries is drawn by uPlot and is not reached by this. uPlot binds mouse events only, so its cursor is placed on a touch screen by the browser's compatibility mousemove and there is no gesture to end — a tap leaves it where it landed, as it did before.

10d8276

Say which renderer reads which axis and plot option, and stop decoding the ones nobody draws.

scale and reversed were parsed on both native platforms and read on neither; gridDash and plot.clip were parsed on Android and read nowhere. Decoding an option you do not draw is a trap for whoever reads the module next, so those four are gone from the Swift and Kotlin side — the contract keeps them, because the web does draw them.

What is left is a real gap rather than dead code, and the axis docs now name each one: scale and reversed are the web's alone, gridDash is the web and iOS, position: 'end' moves the web and iOS axes while Android honours it on the y axis only, plot.clip is iOS and four web forms, and plot.borderRadius rounds a native plot and does nothing on the web.

10d8276

Let hover: 'none' actually switch the gesture off, on both native platforms.

It is documented as the off switch and was read as neither. iOS tested whether hover had been set at all, so passing 'none' announced a gesture rather than declining one; Android compared it against 'none' but let the tooltip's own default turn the gesture back on behind it. A chart handed hover: 'none' — a chart mid-placeholder, say, where there is nothing to read yet — kept following the finger on both.

Now 'none' is decisive on each: nothing else the chart passes turns a gesture back on. A chart that names no hover at all is untouched, so this only reaches the ones that asked.

10d8276

Give every range annotation on the web its own colour again.

The band styling was set once on the series from the first annotation in the list, so a second annotation.range was drawn in the first one's colour and opacity however it was declared — a quarter shaded green next to an incident window that had asked for red came out green as well. The styling now travels with each band, which is where iOS and Android had it all along.

10d8276
0.4.0MinorPatch

Put a rule on a category's edge instead of through its middle.

A category is a band — a width, not a line — so a rule placed on one has to pick somewhere inside it, and every renderer picked the middle. That is right for a rule that names its mark and wrong for the commoner case: a rule that means _up to here_. A cumulative chart's "now" belongs at the end of the last hour, not halfway through it, and drawn through the middle it reads as cutting the last reading in half.

annotation.line({align: 'end'}) moves it to the band's trailing edge, 'start' to its leading one, and the default stays 'center', so nothing already drawn moves. It means nothing on a numeric axis, where a value is already a position.

Android computes it where it computes every other category position, so the reported geometry agrees with the pixels. iOS offsets the RuleMark by half a band, which it now measures off the laid-out plot — a mark cannot ask how wide its own band is. The web renderer still centres: align is read on iOS and Android only for now.

3ef135f

Square the first and last axis labels up with the ends of the row they bracket.

A label is centred on the mark it names, which is right in a row of them and wrong for a pair of bookends. Two hours naming a day — 0:00 and 23:00 — are read as the ends of the axis rather than as two readings on it, and centred they hang half a label outside it at each end: on a plot that runs to the edges of the screen the first one starts before the data does and the last one finishes after it, which reads as the labels being out of line with everything above them.

xAxis.labelEdgeAlign hangs a corner on the mark instead of the middle: the first label's leading edge on the first mark, the last one's trailing edge on the last, and everything between stays centred where it was. All three renderers read it on the x axis — ECharts through alignMinLabel and alignMaxLabel, Compose by shifting the two labels by their own measured width, and Swift Charts by anchoring the label to its mark, which it can only do for the labels a tickValues named.

3ef135f

Give an axis a mark at every category, not only at the ones it names.

xAxis.tickValues says which categories are labelled, and the tick came with the label — so an axis naming two hours out of twenty-four had two marks on it and read as nothing. What the eye takes for the axis in that case is the row of small marks between the labels, and there was no way to ask for one.

minorTicks: true draws a shorter mark at every category and leaves the named ones their full tick and their label. It needs ticks, being the same row made denser.

Both native renderers place them where the marks are, so they line up with the bars or with the readings above them. Android draws them at a fixed short length; iOS gives Swift Charts an explicit AxisTick length, because the automatic one is as long as the label and a row of those is a comb through the words rather than an axis under them.

3ef135f

Style the crosshair's label into the chip your design asked for.

crosshairStyle takes labelBackground for a fill behind the words, labelPadding for the room around them — a number for both ways, or {x, y} — labelRadius for its corners, defaulting to a round cap, and labelLift for the gap above the plot. Without a background nothing changes: the label is the text, lifted 8 as before.

It is there because the alternative costs more than it looks. A chip of your own placed from a scrub handler crosses into JavaScript and back before it moves, so on a fast drag it lands well behind the crosshair it belongs to — and on release it has already jumped to the latest reading, which is what fades out. The chart draws this one where it draws the line.

3ef135f

Let the marker put a dot on the reading, so the dot keeps up with the finger.

marker.segment({dot: true}) and marker.trail({dot: true}) draw the dot of 'point' on the mark under the finger as well as lighting the line, sized by size and glowing by glow. The chart draws it, which is the whole point: a dot placed from a scrub handler has to cross into JavaScript and back before it moves, so on a fast drag it trails the finger by tens of points while the crosshair beside it does not. Every renderer already knew where the reading was.

The web catches up on 'point' at the same time. It drew nothing for the dot styles and only ever lit the stroke, so a chart asking for marker.point got a crosshair and no mark.

3ef135f

Let a chart run its marks to the edge of the plot.

plotDimensionStartPadding and plotDimensionEndPadding were read as extra room on top of what the renderer already kept for itself, so a 0 was not zero: the web grid held 4 points at the leading edge and 8 at the trailing one, Android held 20 and 12 wherever no axis was drawn, and iOS — which adds nothing — put the same window a full 20 points further left than Compose did. A padding that cannot say _none_ is no use to a chart that spans the width of the screen.

What the axis gives is now the whole gutter. 0 puts the first or last mark on the plot's edge on every renderer, and leaving it out keeps the breathing room each one kept before, so nothing that did not ask for a number moves. A chart that did ask gets what it asked for, which on the web and on Android is a few points tighter than it got yesterday.

3ef135f

Let the chart draw the span under two fingers, so both ends keep up with them.

interaction({range: true, rangeStyle: {...}}) and the held stretch is painted by whoever is drawing the line: color and downColor take the direction the span itself went — a fortnight down inside a year up reads as the fortnight — dimOpacity steps the rest of the trace back behind it, and dot puts the reading marker on each end. Leave rangeStyle off and a span is the rules at its ends, exactly as before.

It is the same reason marker.dot exists, twice over. The way to paint a split before this was to feed the two indices back through a scrub handler as a second series masked to the span — which crosses into JavaScript and back, and comes back as a whole new chart rather than as a moved dot. So the ends trailed the fingers by more the longer the series was, and two fingers cost what one never did: one finger changes nothing in the props and the chart never rebuilds under it. Now nothing about a held span reaches the props at all.

The step back a span asks for is its own number rather than interaction.dimOpacity, so one finger can read a whole trace and two can still spotlight a stretch of it — and unlike dimOpacity it reaches the area fill, because a span picks out a stretch of the period rather than one mark out of many. iOS and Android only, like the span it draws.

3ef135f

Give the step back a duration, so a finger landing reads as the lights coming down.

interaction({dimDuration: 420}) and the marks the reader is not on ramp to dimOpacity over that many milliseconds instead of cutting to it, in both directions. Default 0, which is the cut every chart has always had — worth a beat where the dimming is the whole feedback for the gesture, and worth leaving alone where a tooltip is doing the talking.

The lighting on the reading is put up and taken down with the step back rather than with the finger. A trail or a segment dropped the moment a touch lifts leaves the length of trace it was lighting to come back up with everything else, so the part that was never dimmed flashes along with the part that was — the one thing a ramp is supposed to avoid. It stays on the reading the touch left behind until the step back is all the way up, and walks from the marker's colour to the trace's own as it goes, so there is nothing to see when it is finally let go of.

Android also stops dimming charts that never asked. dimOpacity has a default there for the series emphasis to fall back to, and reading it for a scrub meant a chart that only wanted a crosshair stepped its trace back under a finger. Absent now means what it means on the web and on iOS: a reading dims nothing.

Nothing transitions a value that a renderer only reads while it draws, so each of the three drives its own frames. The web ramps lineStyle.opacity with requestAnimationFrame, because ECharts does not animate a style merged into a live series. Android collects the reading through a snapshot flow and reads the ramp inside the Canvas draw, so neither the finger moving nor the ramp running costs a recomposition; a finger lifting mid-ramp turns it around from where it is rather than queueing behind it. iOS walks the strength on a TimelineView the way the reveal and the morph walk theirs, above the chart rather than in the background beside the canvas it feeds, because the state a ramp walks has to outlive every layout of the plot.

3ef135f

Read a span with two fingers, not just a mark with one.

interaction({range: true}) and a chart reports what is under both fingers and everything between: range on the interaction event, with startIndex and endIndex in data order however the fingers are placed. useChartScrub returns it beside selection, and only ever one of the two is set — so a headline shows a value or a total without having to work out which report arrived last, and a finger lifted from a pair goes back to a single reading with no gesture ending in between.

The span carries where each end's mark landed as well as which one it is. A total belongs over the bars it covers, and an app cannot work that position out from the plot's width alone without also knowing the axis padding and the bar inset — the chart is the one that knows, so it says.

iOS and Android only, because a pointer has no second finger; on the web range stays null. SwiftUI's DragGesture reports one location however many fingers are down, and the gesture that would report more is iOS 18, so iOS drops to UIKit's own touch delivery for the charts that ask for a span and keeps the existing drag for every chart that does not. Android reads its pointers in one awaitEachGesture loop instead of the tap and drag detectors, on the same condition. Both draw a rule at each end of the span rather than a crosshair through it, placed where the span ends rather than through its last mark, so the outermost bars read as inside it.

3ef135f

Put an Android category where its mark is, not where its band is.

A trace on Android runs corner to corner — its first mark is the plot's leading edge and its last is the trailing one — but everything placed by category went to the middle of a band, half a step short of the mark at the end of the data. A dot annotating the last reading landed off the end of the line it belonged to, and on a steep close it read as sitting above the trace as well.

Category positions now follow the marks they are placed against: on the plot's edges for a line, an area, a time series or a sparkline, in the middle of the band for a bar or a candle, where they always were. The reported geometry, annotation.line({align}) and the rules at the ends of a two-finger span all move with them, so an overlay calibrated off an annotation still lands on the pixels. Axis labels keep naming their band.

3ef135f

Let an Android category axis name only the ticks it was given.

xAxis.tickValues takes numbers or category names, and Android read the array as numbers only — so every name in it was dropped on the floor and the axis went on drawing all of them. On a month of days that is thirty-one labels in the room for four, each one ellipsised to ..., which reads as a rendering fault rather than as an axis that was asked for something.

The values are now kept as written, and a category axis draws the labels and ticks it was told to. The bars are untouched: a category with no label still has its bar, and the label that is drawn is measured against the room the named ones actually have rather than against one band of thirty-one, which is what was cutting them short.

The y axis has always honoured its own tickValues for labels. Its gridlines still do not — a chart that asks for two rules gets the renderer's own count — so that is still worth knowing before matching a design that leans on them.

3ef135f

Stop an Android trace from stretching over the empty end of its own axis.

A line or an area spread its marks across the whole plot no matter how many categories the axis had — one step per reading rather than one per slot. A window that ends before its period does, which is what an intraday chart is, therefore drew its last reading at the trailing edge while everything placed by category stayed where the category was: the annotation on the latest price sat in open space with the trace running on past it, and the scrub stopped short of the end of the line it was reading. Marks now stand on their own slot, the way they already did on the web and on iOS, and a trace shorter than its axis runs out where its data does.

3ef135f

Stop iOS drawing a line across the top of every bar chart.

The canvas that strokes a series' trace was attached to every form ZyplotMarksChart renders, not to the ones that have a trace. On a line or an area that canvas _is_ the chart. On anything drawn as bars or as points it is a second, uninvited chart on top: a polyline through the bar tops, in the series colour, which reads as a rendering fault because it is one.

It went unseen because it takes a screen built on bars to notice — a gallery example is glanced at, and the line looks almost like an axis until the bars are the point. The trace canvas and the scrub highlight canvas now both check the form first: line, area, sparkline and time-series keep them, and the bar, histogram, scatter, heatmap and boxplot families are left to their own marks.

3ef135f

Thin a solid fill downwards on iOS and Android too, so fadeTo means the same thing on all three renderers.

The SwiftUI and Compose canvases read fadeTo only on the way to a dot grid, which is the one place it had to be a per-row alpha. A solid fill went down as one flat wash at full strength, so a quote chart asking for fill({fadeTo: 0.06}) got a slab of colour with a hard edge along the floor — the exact edge fadeTo exists to remove. It is now a vertical gradient over the plot, from the colour at the top to the same colour at fadeTo of its alpha at the bottom, with fillOpacity still scaling the whole thing.

Fills that never set fadeTo, and dotted ones, draw exactly as they did.

3ef135f

Draw every placeholder in the plot the chart is about to use, on all three renderers.

A placeholder is only worth having if nothing moves when the data lands, and none of the three were keeping that promise. The web frame reserved a fixed left gutter for the value labels, six of them however many the axis had been pinned to, eight bars however many categories there were, and a 26 point floor — while the real plot gives up the whole row the category labels hang in below that floor, half a label wherever one is centred on an edge of it, and the width the value labels need on whichever side they are on. An overlaid axis was the worst of it: the placeholder kept a gutter on the left for labels the chart draws on the right, so the bars arrived some thirty points away from where they had been promised, in a plot nearly thirty points shorter than the one they were drawn in.

ChartSkeletonFrame now takes the axis options themselves rather than a pair of booleans — the same objects the chart was given — and works the geometry out of them: the gutter on the side the labels are on and no wider than the widest of them, a label for each tickValue at the reading it was pinned to, the category labels on the middle of their own bands, and the plot's own insets including plotDimension*Padding. SkeletonBars lays its bars out in bands, four fifths of each and never wider than barMaxWidth, so a week of seven and a month of thirty are spaced the way they will be. Chart.Bar also passes its xAxis to the grid, so those paddings are honoured on the web the way they already were on native — the one cartesian chart that was dropping them.

Native drew across the whole view: eight columns from the bottom edge of it, no matter what the axes were taking or how many categories there were. Android now draws in plotRect, the same rect the bars themselves are drawn in, and one band per category. iOS keeps the same gutters as Android — the label row under a visible x axis, a label's width beside one in a gutter, and overlayAxisGutter where the labels sit inside the plot.

All three now also lay down the axis itself: the rules the value axis draws at the readings it was pinned to, and on the web the line the category axis draws along the plot. They are the chart's own furniture rather than a value still to come, so they are drawn once in the grid colour and left to sit — only the marks shimmer. iOS draws no rules for an overlaid axis, since that is a chart with no y axis marks at all: it keeps the same guard ZyplotChartAxisModifier does.

The axes also get their labels: a pill wherever one is about to be written, on the line it will be written on — the readings the value axis names, held inside the plot's trailing edge where the axis is overlaid and clamped the way each renderer clamps them, and the categories the x axis names, each on the middle of its own band.

iOS was the one renderer whose plot was guessed rather than derived, and it was wrong: Swift Charts gives up only the row its labels are written in, some 21 points for a 13 point label, where the placeholder was keeping Android's 44. Measured off a real chart, its plot starts 13 points down and ends a label row above the bottom, which is what the placeholder keeps now — within a point of the real thing, labels included.

The standalone .Skeleton components take the same things, since a Suspense fallback has no chart above it to fill them in: xAxis and yAxis are now boolean | NativeChartAxisOptions — false for an axis the chart hides, true for the label row alone, or the options themselves — beside categories for the names the axis will write, format for how the value labels will read, and orientation for the forms that can be turned. Chart.Bar.Skeleton keeps a count for a fallback that has no categories to count. Everything already written keeps working: the booleans mean what they meant.

Two smaller things came out of it. A placed label was landing nowhere: Skeleton carries its own relative for the pulse it holds and cn only joins classes, so absolute never won and every pinned label was offset from wherever the flow had left it. They are placed by a wrapper now. And the bars reach less far up the plot — an axis rounds its domain up past the tallest bar, so a placeholder that filled the plot arrived taller than the data ever was.

3ef135f

Put a pointer cursor over a web plot that answers the pointer.

A chart that reads the pointer — a crosshair following it, a tooltip under it, a readout above the plot moving with it — said nothing about being interactive until it was touched. The arrow stayed an arrow over the one region of the page that behaves like a control, which is the cheapest signal there is and the only one available before the pointer has arrived.

The chart's own wrapper now carries data-zyplot-interactive when its plot reads a pointer at all, and the stylesheet takes the cursor off it. The rule lands on the canvas rather than on the wrapper, because both engines put something between the two: zrender writes cursor: default inline onto the div it owns, and uPlot lays a bare overlay over its canvas — a rule on the container loses to the first and is covered by the second. The attribute is part of the CSS contract, so a page that wants its own cursor, outline or hit affordance can hang a selector off the same hook.

It is on the forms that read interaction — Chart.Line, Chart.Area, Chart.Bar, Chart.StackedBar and Chart.Candlestick — where interaction.hover: 'none' takes it off again, and on Chart.TimeSeries, which reads a pointer by construction.

3ef135f

Thin a solid fill downwards on the web, the way fadeTo already promises.

ChartSeriesFill.fadeTo says how much of its strength the paint under a line still has at the plot's floor, and nothing in it is about dots. The web read it only when the fill was a dot grid — a solid one took a flat wash at full strength for its whole height, so a quote chart's area arrived as a slab of colour instead of gathering under the trace. A solid fill with fadeTo below 1 is now a vertical gradient over the filled shape, from the colour at the top to the same colour at fadeTo of its alpha at the bottom, with fillOpacity still scaling the whole thing.

Fills that never set fadeTo, and dotted ones, draw exactly as they did.

3ef135f
0.3.0MinorPatch

Let the crosshair carry a label, so the text above it keeps up with the finger.

crosshairStyle.labels takes one string per slot in data order — a time, a date, whatever the reading is called — and every renderer draws the one for the read slot above the plot, in the same pass that draws the line. The app still writes the words; the chart only places them.

This is the one piece of scrub chrome worth taking back off the app. Everything else an overlay draws over a plot sits still long enough for onInteraction to place it — a card against a rule, a badge on an annotation — but a label pinned to the crosshair has to move with it, and a position that reaches JavaScript through a bridge and comes back as a re-render is a frame or two behind the line it belongs to. Reading the two together, the label visibly drags. Nothing about the overlay contract changes; this is one thing added to the side of it that was always going to lose that race.

66541ab

Add fadeTo to the series fill, so the paint can thin towards the plot's floor.

An even fill has two edges: the trace along its top, and a hard stop along the bottom of the plot that no data put there. On a chart with no axis to speak of the second one reads as a second line, and the eye keeps going back to it. fill({fadeTo: 0.12}) takes the fill down to a tenth of its strength by the floor, so it gathers under the trace and lets go.

The three renderers get there differently. The SwiftUI and Compose canvases paint the dot grid a row at a time, which is the largest unit that can share an alpha — one path per dot would be thousands of draw calls a frame, and one path for the grid can only carry one. ECharts is given a tile as tall as the plot and repeated only across, because a tile that repeated vertically would restart the ramp every few pixels; that needs the plot height before the chart is measured, so it is computed from the same gutters the grid reserves and a chart given no height keeps an even fill rather than guessing at one.

66541ab

Add marker.trail, a selection marker that lights the trace up to the reading.

marker.segment brightens a window span steps either side of the mark under the finger, which reads as light moving along the data. That is the right shape when the reader is comparing a point against its neighbours, and the wrong one for a price chart, where the question is what has happened _so far_ — everything before the finger is history, everything after it has not been reached yet.

There was no way to say that: the window is symmetric in all three renderers, and dimOpacity fades the whole line at once. marker.trail lights from the first datum to the reading instead and leaves the rest at dimOpacity, on Swift Charts, the Compose canvas and the web. It takes neither span nor size — its far end is the reading and its near end is the start of the series, so there is nothing to size.

Both stroke-lighting styles are drawn over the line rather than beside it, so both still need a dimOpacity to stand out from. The iOS and Android selection-marker views now treat a trail the way they already treated a segment, and draw no dot of their own on top of it.

66541ab

Make transition: 'morph' do something on iOS and Android.

ChartTransition has offered 'crossfade' | 'morph' since the contract was written, but only crossfade was ever implemented. Asking for a morph did not fall back to a crossfade — it fell through to no animation at all, and the plot cut from one dataset to the next.

Both platforms now blend the two: the readings, the pinned axis domain, and the value a rule or a point sits at, so nothing on the plot jumps while the trace is on its way. Annotations are matched by id — one that exists on both sides slides, one that does not simply arrives. A morph needs the two sides to correspond, so where the series count or the reading count differs the new dataset is shown as it is: half a morph reads worse than none.

animation({duration, easing}) times it, the same two that time the entrance and every other data change — so a morph is tuned from the props rather than from a curve baked into the renderer. 'spring' resolves to the eased curve: there is no closed form to sample at a fraction, and a transition that overshoots would carry the marks past their new values and back.

The frames are produced rather than animated towards. SwiftUI and Compose both interpolate _modifiers_; a fraction fed to a _data_ computation is set straight to its final value and the body runs once, which is an animation that never draws a frame. iOS runs the clock off a TimelineView, the way the traced reveal does, and parks the schedule whenever nothing is moving. Android reads the clock inside the canvas, so a morph costs a draw a frame rather than a recomposition of the chart around it.

Implicit animation is switched off underneath the iOS morph — the chart's own update animation with it, which is the one that mattered. An animation keyed on the data is handed a new target on every frame of a morph and spends the whole morph chasing it: the trace is drawn from the frame directly and lands on time, while every mark Swift Charts owns arrives a beat late and keeps moving after the line has settled. The rule at the latest reading showed it worst — it hung at its old price and then slid down once the morph was already over. Compose draws straight from the frame it is given, so it never had the second animation to switch off.

A morph cut short sets off from what is on screen rather than from the dataset the last one was heading for. A row of range buttons gets pressed in sequence, and snapping back to the window before to set off again is a jump nobody asked for.

Two datasets only correspond if they agree on how many readings they have. A screen that switches between windows of different lengths — a day against a year — has to sample them into the same number of slots to be morphed between; otherwise this stays a crossfade.

transition stays a native choice, and is now documented as one. The web renderer transitions a data change itself, mark by mark: the ones on both sides move, the ones on one side fade. That is a better answer to a changed axis than dissolving the whole plot, so a web chart does it whichever name it is given — including for the same animation object an app shares with its native screens.

66541ab

Add fill to the series style: a dot-grid pattern, and a baseline other than the plot floor.

fillOpacity was the whole vocabulary for the area under a line, and it only did anything on Chart.Area, where the fill runs to the bottom of the plot because there the fill is the quantity. Two things a price chart wants were unsayable.

A fill can now close against a value — fill({baseline: latest}) — so the shape between the trace and that number is filled above the line and below it, and reads as distance from where the asset stands rather than as volume. And it can be laid down as a grid of dots rather than a flat wash — fill({pattern: 'dots', spacing: 3.4}) — which carries a fill across a pale background without the second, fainter chart a wash leaves behind it.

fill lives on NativeChartSeriesStyle next to glow, so every entry point takes it, the web one included: a clipped dot path on the SwiftUI and Compose canvases, a repeating canvas pattern on ECharts. Opacity is still fillOpacity — one spelling — though a dot grid usually wants more of it than a wash, since most of what it covers stays bare.

Giving a series a fill also paints an area under Chart.Line, where it is decoration rather than the quantity. Chart.Area is unchanged: it still fills by default, and a fill only overrides the pattern and the baseline.

66541ab

Stop Android drawing grid rules for an axis that asked not to be drawn.

yAxis={{visible: false}} took the labels off the Compose canvas and left the rules behind: drawGrid only ever consulted yAxis.grid, which defaults on, so a chart meant to be read off its marks alone came out with five grey lines across it — and only on Android, since Swift Charts and ECharts both drop a hidden axis' grid with the rest of it. A plot styled to bleed off the window showed the divergence at its clearest: the rules stopped short of the right edge, where the axis' end padding is.

The grid now follows the axis. grid still turns the rules off on their own for an axis that is drawn, which is what a chart wanting labels without rules already passes.

66541ab

Draw the Android crosshair and selection marker wherever the scrub reaches, not only where the finger happens to land inside the plot.

Both asked whether the plot _contained_ the pointer and drew nothing when it did not, while the reading itself is picked off the pointer's x alone and clamped into the plot on the way. So a finger in any of the four bands the plot is inset by — 20dp at the leading edge, 16dp at the top, 24dp under the marks — moved the trace, lit the trail and reported a reading, and put neither a line nor a mark nor a label on the chart to say which one. On a plot the height of a headline that is a quarter of the chart's own height, and it includes the two edges a finger is most often taken to.

Rect.contains is half-open besides, and a scrub is clamped to exactly plotRight — so the last reading, the one the end dot marks, was one of the dead bands.

The pointer is now brought onto the plot rather than tested against it: the line stands at the edge and stays there while the finger goes on past, which is what the reading does too.

66541ab

Stop an Android scrub dying on the redraw its own first report causes.

The Compose gesture detectors, the scrub state and the entrance animation were all keyed on the configuration string — the whole serialised payload. That holds while a chart is only read from, and breaks the moment an app answers onInteraction by changing the chart: a dimmed end dot, an annotation moved to the reading, anything at all. The first touch reports began, the app re-renders, a new string arrives, and Compose tears down the pointerInput under the finger. A detector restarted mid-gesture is waiting on awaitFirstDown for a finger that is already down, so every move for the rest of that drag goes nowhere. Lift and touch again and it works, because by then the payload has stopped changing — which is why this read as a scrub that took two goes rather than one that was broken.

The scrub and the entrance now key on the dataset, the way the morph and crossfade already did — the entrance on the end of the loading skeleton as well, which is the other moment a chart is first seen — and the detectors are started once and read the current handlers rather than the ones composed alongside them. A change of data still clears the reading and still plays the entrance; a change of styling or annotations no longer touches either. The entrance also stops being restarted mid-scrub, which on a chart with animation.delay set was holding the trace at nothing for the length of that delay on every touch report.

iOS was never affected: SwiftUI holds the selection in @State on a view whose identity does not depend on the payload.

66541ab

Put the axis labels back on every web chart drawn through ECharts.

A gutter axis — the default on both dimensions — asked for no label inset, and asked for it by handing ECharts axisLabel.margin: undefined. An option given explicitly wins over the engine's own default even when it holds nothing, so the 8px gap the labels are laid out from went missing, and every label on the axis was placed at the canvas' corner rather than beside its tick: a pile of overlapping text clipped by the plot's leading edge, no scale to read anywhere, and a plot pushed down the canvas by the room the same measurement asked for.

The inset is now set only where a chart means to move a label — an 'overlay' axis, against the plot's trailing edge — and left off entirely everywhere else. Chart.Candlestick built its category axis by hand and carried the same bug, including for an overlaid axis that named no labelInset; it now takes the shared inset with the rest.

66541ab

Give Chart.Candlestick a placeholder shaped like a candlestick chart.

Chart.Candlestick.Skeleton was BarChartSkeleton, and so was the placeholder the chart itself drew while loading: a row of bars grown from the baseline. A candle does not sit on the baseline — it floats on its wick — so the shape that landed was never the shape that had been promised, and the swap moved every mark on the plot.

The candlestick now has its own: bodies of varying height, each centred on a wick, each offset up or down the plot the way a real series wanders. Nothing to configure — like the other placeholders it is derived from the props the chart already has.

66541ab

Stop the iOS crosshair label being written off the edge of the window.

The label is centred on the line, and a line read near the start of a series puts half of a date off the left of the screen — Feb 6, 2026 arriving as b 6, 2026. Android has always pinned it inside the chart's own bounds; iOS laid it out as a fixedSize overlay on a hairline, which is a box with no width to be constrained by, so nothing ever stopped it.

It now stops when its own edge reaches the view's, on both platforms and at both ends: the label follows the line until it would hang off, then anchors against the edge and stays whole while the line goes on without it.

Where it stops is worked out as arithmetic and applied as a shift off the line, rather than written back as an alignment guide: an overlay places its content by the alignment it was given, and a guide the content returns does not reach it. The width that arithmetic needs is measured off the font the label is drawn in, so it is the width the label actually takes.

66541ab

Stop the iOS area fill from dimming while a point is being read.

dimOpacity is how far the data the reader is _not_ on steps back, and on the web and on Android it has always applied to the stroke alone. iOS applied it to the whole line canvas, so the area under the trace faded with it — which greys the page rather than pointing at anything, because the fill is the ground the trace is drawn on and not one of the marks being compared. The three renderers now agree.

66541ab

Stop a hover from dimming a web line chart's fill, and from dimming its stroke twice.

dimOpacity says how far the data being read steps back, and the pointer layer applies it where it belongs: the stroke, on the series' resting style. Chart.Line was also handing ECharts emphasis.focus, which puts the series into its own blur state on hover — and that state takes the whole series down, areaStyle included. A line with a fill under it lost both at once, and the stroke was dimmed twice over, once by each mechanism.

A chart that names dimOpacity is saying it will do the dimming itself, so ECharts' focus is now switched off when it does. Charts that name no dimOpacity are unaffected and keep the focus behaviour they had.

66541ab

Let isLoading={false} keep the first-frame placeholder off the page.

A web chart cannot paint until it has read its colours off the document, which happens in an effect, so the built-in placeholder covered that first frame for every chart — including one mounted with its data already in hand. On a page that swaps charts as you click through them the cost is visible: a grey shape fades in and out again for a chart that was never loading.

An explicit isLoading={false} on the first render now opts out of the placeholder for good, and the plot fades in on its own. A chart that says nothing about loading keeps the old behaviour, which is what a server-rendered page wants: markup to paint before hydration. One that starts true and later flips to false still cross-fades — the placeholder it was showing stays mounted to fade out.

66541ab

Export fill from @hzblj/zyplot/ios and @hzblj/zyplot/android too.

The builder landed on the shared entry point and the web one and was left off the two platform subpaths, which are the entries a file already committed to a platform imports from — so the screens most likely to want a dotted fill under a trace were the ones that could not name it without reaching back to @hzblj/zyplot. Every other builder is on all four, and the docs say so; this one is now as well.

66541ab

Give Chart.TimeSeries the same axis spacing as every other form. Its value band was a fixed 48px — uPlot takes a width, not a measurement — so a two-digit scale sat a long way off its plot while the ECharts forms beside it kept their labels 8px away. The band is now measured from the widest reading in it, in the font the chart paints, and both axes take the same 8px gap the rest of the library uses.

66541ab

Honour animation on every web chart, not five of them.

ChartBaseProps has always declared it, and Chart.Line, Chart.Area, Chart.Bar, Chart.StackedBar and Chart.Candlestick have always read it. The other thirteen forms — pie, scatter, heatmap, histogram, boxplot, diverging bar, dumbbell, funnel, gauge, radar, sankey, sunburst, treemap — built their options without it, so duration, delay and easing went nowhere and enabled: false turned nothing off. They animated on the renderer's own defaults: a full second of cubicOut, whatever the chart had been told.

A page that sets one animation for every chart on it now gets one animation for every chart on it.

66541ab

Give a web annotation badge the room it needs, and the rule beneath it.

The badge was centred on the plot edge the rule starts from, which put half the circle outside the canvas: on the web that edge is where the drawing stops, so the glyph read as sliced off the top. It now sits a radius inside the edge, the same place iOS and Android hold it, and the whole circle is on the plot.

It also draws over the crosshair and the marks rather than under them. A rule capped by a badge is a pin, and a pin reads that way only while nothing crosses its head — the crosshair, which is drawn full height, went straight through the glyph whenever the pointer stopped on the annotated slot.

66541ab

Keep web annotations on the marks they belong to while the data changes.

Three separate reasons a rule or a point came off the trace on the web, all of them visible on the same range switch.

A point annotation is drawn by the chart itself rather than handed to the renderer — that is what gives it a halo, a glow and a pulse, none of which a markPoint has. It was built from the plot's new scale, so it arrived at the new reading the frame the option landed, while the trace under it was still travelling: a dot hanging in the air above the line for the length of every data change, and a badge hanging off the rule it caps. It now travels there instead, over the same length and along the same curve as the marks, and still snaps when the plot itself has moved — a resize is not a data change and there is nowhere to travel from.

easing reached the entrance but not the data change: those are two different keys, and without the second one every update ran on the renderer's own cubicInOut however the chart had been timed.

A rule is matched across a data change by name, and a rule with no label had none to be matched by. Two of them and the renderer cannot tell which is which — one is drawn again from nothing instead of moving to where it now belongs, which is the rule that flickered and re-entered rather than sliding. They carry their id now, which is the thing that identifies them and is always there.

66541ab

Give the crosshair's label somewhere to be drawn on the web.

crosshairStyle.labels places the words above the plot's ceiling, which is where they belong and where iOS and Android put them: those draw over the chart's own view, and a view carries on past the plot in every direction. A canvas does not. The label was hung upwards off a line eight pixels below the top of the chart, so all of it was painted off the top and none of it was ever seen — the crosshair arrived on the web with no words on it at all.

The plot now gives up the room the label needs, measured from the same two numbers the label is drawn with, so the space and the text cannot drift apart. Only a chart that was given labels gives anything up, and it gives it up whether or not a pointer is over the plot — marks that changed height the moment one arrived would be worse than either.

The label is also kept whole against both edges, the way iOS pins it. A crosshair reaches the ends of the plot and a date is wider than the hairline it names, so the first and last readings of a series were worth half a label each.

66541ab

Stop reveal.fade from painting the stroke out again on the web.

A fade entrance starts the line at lineStyle.opacity: 0 and runs a tween that lifts it back to full. The tween runs once, by design — the entrance belongs to the first render — but the zero was written into the option on _every_ rebuild, so the first change of range, theme or data after it had finished set the stroke back to nothing with nothing left to bring it up. The line vanished while its fill, annotations and axes stayed, which reads as the chart having lost its data rather than as an animation bug.

'draw' already guarded its own startOpacity behind hasPlayed; 'fade' now does the same. This only ever affected charts that asked for reveal.fade explicitly — a chart with no animation.reveal never took the branch.

66541ab

Let a web line chart's marks travel to a new dataset again once its entrance has run.

A data change was supposed to move the marks reading by reading. It cut instead — the whole trace at the new dataset the frame the option landed — while the rules and the points drawn over it travelled the full length of the change on their own. So a range switch read as the plot jumping and its annotations then sliding into place after it, which is the opposite of what transition: 'morph' promises anywhere else.

The cause was the entrance, of all things. A fade is the stroke's own opacity changing after the marks have landed, which no option covers, so it is driven frame by frame and written back through setOption — and each of those writes has to turn the renderer's update tween off, or every frame of the fade is chased by one. That key is merged into the read series and stays there. Once an entrance had run, the series had no update animation at all, for the life of the chart. Everything drawn over it kept the chart's own timing and travelled, which is why the two came apart rather than both cutting.

How a data change is timed is now restated on the series as well as on the chart, so the option after an entrance puts back what the entrance took away.

Also fixed: the first frame of a fade painted the trace at full strength. A frame's timestamp is when that frame began, which can be before the fade was asked for, and an unclamped ease-out of a negative elapsed is a negative opacity — not dim but invalid, which a canvas answers by keeping whatever alpha it had.

66541ab

Keep a web scrub's dim, its lit stretch and its stepped-back marks for the whole gesture.

The pointer layer says how the plot reads while one mark is being read — the trace steps back to dimOpacity, the marker relights the stretch that has been got to, and any annotation that asked to comes back with it — and it said it once, at the start of the gesture, by patching the chart's option. A chart builds that option for a plot at rest, so the next one to land merged every one of those away again.

Which sounds rare and is not. An app is told which reading is being read, and anything it does with that comes back to the chart as a prop: the screen this was found on dims its end dot once the finger leaves the last reading, so the very first scrub event rebuilt the option and undid the dim that same frame. What was left was a crosshair over a plot that had not otherwise reacted — and a lit trail the same colour as a trace that was never dimmed, which is a trail nobody can see. The gesture's state is now restored whenever a new option lands under it.

scrubOpacity also reaches the marks the chart draws itself rather than handing to the renderer — a point with a halo, a glow or a pulse, and the badge that caps a rule. iOS and Android fade every annotation that asks; on the web the reference lines faded and those did not, so a dot marking the live reading stayed lit while the answer was being read somewhere else. Two marks, one question.

66541ab

Draw a web reference line solid when it was not asked to be dashed.

annotation.line() with no dash is a solid rule, and is one on iOS and Android. On the web it came out dashed, because a reference line is the one thing ECharts has an opinion about: its markLine defaults to 'dashed', and the key was handed over holding nothing — which leaves a renderer's own default standing rather than overriding it, the same trap the axis label margin was in. The rule standing in for an axis on a plot that has none was a row of faint dashes instead of the hairline it was written as.

66541ab

Make sure a web chart's stylesheets actually reach the page, which is what Chart.TimeSeries was missing to draw at all.

The two stylesheets a chart needs were imported by the web entry point, which does nothing but re-export. A bundler is free to read a re-export, resolve the symbol to the module that declares it and never run the file in between — Turbopack does exactly that — so an app got the charts and none of the CSS. uPlot's is structural: without it the plot is not positioned, the canvas is not scaled to its box, and Chart.TimeSeries painted a stretched grid and giant axis labels sliding out of the card with no line in sight. They now sit with Chart itself, reached by the same import that reaches a chart, which no tree shake can drop.

That stylesheet now arrives in the base cascade layer, because an app with Tailwind of its own ends up holding two builds of the same utilities under the same names, and this one — pulled in by a chart — comes second. A plain .flex or .hidden from here beat the app's own dark:block and min-[821px]:hidden on the app's own markup, since a variant carries no more weight than the utility it varies: dark-mode pages showed their light-mode element, and elements meant to be hidden at a width stayed on screen. In base these lose to everything an app writes, while a page whose only stylesheet is this one renders exactly as before.

66541ab
0.2.0MinorPatch

Give an annotation label a chip and a side that keeps it readable. labelBackground paints a rounded fill behind the label, so a rule's value stays legible where the marks run through it, and labelPosition: 'auto' picks the side from where the rule sits: above it in the lower half of the plot, below it higher up. Fixed sides still win when named, so nothing changes for annotations that already pass 'top' or 'bottom'.

54b15f6

Let a rule annotation set its own thickness. width joins dash and color on ChartLineAnnotation, honoured on iOS, Android and the web — the line width was pinned at 1 in the native drawing code, so a reference line could be dashed and coloured but never made heavier or lighter than the hairline it started as.

Android drew both the width and the dash lengths in pixels while they are given in dp, which on a 3× screen made a dashed rule a third of its asked-for thickness with a third of its asked-for dash; both are scaled now.

54b15f6

Put your own component where an annotation lands, with annotationViews.

The chart already reported where every annotation ended up, and an app that wanted a logo at the live reading or its own head on a rule had to take it from there: hold the geometry in state, absolutely position a view over the plot, keep the two in step. That work was the same every time, so it lives in the chart now. Key a node by the annotation's id and it is centred on the spot, moves with the data, and the mark the chart would have drawn there is left out — one prop instead of an overlay of your own.

tsx <Chart.Line annotations={[ annotation.point({ id: "live", x: live.category, y: live.value }), ]} annotationViews={{ live: <LivePrice value={live.value} /> }} categories={categories} series={series} />

The annotations you leave out keep the dot, glow and pulse the renderers draw, on all three platforms, and nothing about the built-in marks has changed. An annotation can also be an anchor and nothing else: hidden: true keeps it measured and reported in geometry while drawing none of it, which is what a view of your own placed by hand — a card following the finger, say — wants underneath it.

Charts that draw annotations but had no pointer layer to measure them (Chart.Area, Chart.Bar, Chart.StackedBar on the web) now report geometry on the 'layout' phase like the rest, so the views land there too.

54b15f6

Report where the plot and its annotations ended up, so an app can draw its own overlays instead of the ones the chart bakes in. Native charts now emit a 'layout' phase carrying geometry — the plot's box and every annotation's position, in the chart view's own coordinate space — and useChartScrub returns it as geometry alongside the selection, which also carries the pointer's nativeX/nativeY now.

That is enough to place any React component over the chart: your own badge on an event annotation instead of the built-in glyph-in-a-circle, your own card at the reading under the finger, a logo, a button, whatever the design asks for. Leave badge off the annotation and the chart draws only the rule, leaving the head to you.

54b15f6

Let every entry point hand over the whole contract, not most of it.

@hzblj/zyplot/ios and @hzblj/zyplot/android re-exported the shared types and Chart, and then stopped: the builders and useLastReading are values, and export type * does not carry a value. So the import the documentation shows for a platform file — import {annotation, Chart} entries now export the fifteen builders and useLastReading alongside useChartScrub, so a *.ios.tsx file needs one import rather than two.

@hzblj/zyplot/web re-exported a hand-kept subset of the shared types, and several a web chart actually needs were missing from it: ChartCandlestickDatum and ChartCandlestickStyle, which Chart.Candlestick takes; ChartRangeAnnotation and ChartTextAnnotation, two of the four members of the union annotations is; StyledChartSeries, what the series builder returns; and the small unions the documented shapes are written in terms of — ChartSymbol, ChartAxisScale, ChartCoordinate, ChartSurfacePadding and the rest. Typing a candle array or a helper that returns a range annotation meant importing from @hzblj/zyplot-core directly. They are all re-exported now.

Chart.TimeSeries was also the one web form whose list prop stayed mutable: its series is readonly Omit<ChartSeries, 'values'>[] now, like every other list the web charts take.

54b15f6

Two knobs for reading a candlestick chart. interaction.highlightBlend says how far the read mark is lifted towards interaction.highlightColor, so at 0.5 a red candle lights up red instead of turning white — a flat replacement threw the series colour away, which is the one thing a candle's colour is for. style.candleRadius rounds the candle body, and rounds the wick's caps with it so the wick does not read as a cut-off stub against a rounded body.

54b15f6

Let the mark under the pointer light up. interaction.highlightColor draws the read mark in its own colour, so a scrub reads as one candle lit rather than as every other candle merely dimmed — dimming alone leaves the read one in its resting colour, which is hard to pick out against a plot that has only lost a little contrast. Implemented for candlesticks on both platforms, alongside the dimOpacity fix that made the rest fade at all.

54b15f6

Draw with the theme's font on iOS and Android, and read the last three theme colours everywhere.

theme.typography.fontFamily reached only the web renderer before: the native ones decoded colors and dropped typography on the floor, so a chart beside a <Text> in the app's own font drew its axis labels in the system one. Both now resolve the family the way their platform resolves text — iOS through the registered-font lookup behind UIFont(name:), Android through React Native's ReactFontManager, which covers assets/fonts, res/font and anything expo-font registered at runtime. A family the app never shipped falls back to the platform font, exactly as a canvas does on the web. It reaches every string either renderer draws: axis labels and titles, tick labels, annotation labels and badges, rule labels, the tooltip and the gauge reading.

Three colours were also being decoded and then ignored:

- axis now colours the tick marks on both platforms. Android drew no ticks at all until now, so its ticks axis option had nothing to switch off; it draws them beside every label the x and y axes place, an overlaid y axis excepted — it reserves no gutter for one to sit in. - surface now fills the tooltip card. It replaces the hardcoded near-black on Android and the system material on iOS, which is still what a chart with no surface in its theme gets. - background now paints the plot on Android when no plot.backgroundColor overrides it, the order iOS already resolved in.

54b15f6

Give each theme shape a name of its own. @hzblj/zyplot/web exported two incompatible types called ChartTheme — the wide palette Chart.Provider takes, and the narrower one a chart's own theme prop takes — and the explicit export won, so a value annotated ChartTheme was not assignable to the prop of the same name.

There are now three, and the two wider ones are supersets of the portable one, so a single object can be passed to any of the three props:

- ChartTheme is the portable subset: axis, categorical, grid, label, negative, positive, surface, track, and typography. Every key on it is one all four renderers draw with. Its colours are ChartThemeColors, exported for building a theme up in parts. - NativeChartTheme adds colors.background, the chart's own fill, which only a native surface paints. background has moved here off ChartTheme: the web renderer never drew it, and the box a web chart sits on is surface.background. - ChartProviderTheme adds border, diverging, muted and sequential — the palettes and greys that only a CSS variable can carry — and is what the web Chart.Provider takes.

Chart.Provider also reads the flat negative and positive now, as the shorthand for diverging.negative and diverging.positive. Passing the five-key diverging object still wins over them, so setting both is not ambiguous.

54b15f6

Give the pulse on a live point a rhythm, and hand it over. pulse on a point annotation now takes a ChartPulse — color, duration, interval, opacity, scale — as well as the true it took before, and true now means one bloom of 450 ms followed by a rest of 1550 ms, at 2.2× the point's resting ring.

That replaces a single 1.8 s expansion that faded to nothing with no rest between cycles: the ring spent almost the whole cycle nearly transparent, which read as no animation at all. The ring's colour is settable too, and falls back to the glow's colour and then to the point's own — on iOS a pulse with no glow used to inherit the glow's .clear and draw nothing at all.

Android had no pulse to speak of — the parameter was threaded through the drawing code but nothing ever animated it — and now draws the same bloom off the same clock.

54b15f6

Make the data shapes one contract across web and native. The web entry point had its own copies of ChartSeries, ChartDatum, ChartTimePoints and the other per-form inputs, identical to the ones in @hzblj/zyplot-core except that their arrays were mutable. It now re-exports the core types, so a value typed once can be handed to a web chart and a native one.

Every web chart prop that takes a list — series, categories, data, nodes, cells, groups, rows, values — now accepts a readonly array, as the native props and web's own Chart.Candlestick already did. Passing an as const array or the result of a readonly-returning selector no longer needs a cast.

54b15f6

Let the entrance name its own curve. reveal.draw and reveal.fade take an easing — 'ease-in' | 'ease-in-out' | 'ease-out' | 'linear' — and reveal.draw also takes a flashEasing for the glow's decay. Both were hard-coded before: a trace always ran at a steady speed, a fade always eased out, and the flash always shed most of its bloom in the first frames after landing, which reads as the glow leaving while the trace is still arriving. flashEasing: 'ease-in-out' keeps the bloom up a moment longer so it leaves in one piece.

A spring is deliberately absent from ChartRevealEasing: an entrance that overshoots would trace past the last data point and come back.

Defaults are unchanged — 'linear' for a trace, 'ease-out' for a fade and for the flash — so existing charts animate exactly as before.

54b15f6

Make the web renderer read a pointer the way the native ones read a finger, so a screen built on a scrub is one screen on all three platforms rather than two.

useChartScrub is now exported from the web entry point as well. The scrub lifetime is no longer native's alone: ChartInteractionEvent carries phase, index and geometry on every platform, and Chart.Line and Chart.Candlestick report them from the pointer — 'began' when it enters the plot, 'changed' as it moves, 'ended' when it leaves, and 'layout' with the plot's box and every annotation's position once the chart has measured itself. NativeChartInteractionEvent stays as a name for the same shape.

The web props also take the fuller presentation vocabulary they were previously typed out of, and the renderer honours it:

- interaction.marker lights the mark being read — a stretch of the line for marker.segment, a bloom behind the candle for a mark that has its own body — with crosshairStyle, dimOpacity, highlightColor and highlightBlend around it. - animation.reveal traces a line open: trackColor lays the shape down first, and flashColor with its glow, hold and decay lands with the frontier and then leaves. - annotations draw their glow, halo, pulse, badge, label, labelBackground, labelPosition and scrubOpacity. - axis.overlay puts the tick labels inside the plot at labelInset, tickValues pins them to exact readings, and plotDimensionStartPadding/plotDimensionEndPadding keep the marks clear of them. - seriesStyles[id].glow blooms behind a stroke, and style.candleWidth/style.wickWidth size a candle. style.candleRadius is the one prop the web cannot honour: ECharts draws a candle as a single path with no corner radius to give. - Every chart takes a theme of its own, merged over Chart.Provider's, so a preset that carries colours can be handed to a web chart and a native one alike.

54b15f6

Give Android candlesticks the entrance they already had on iOS. A traced reveal pins the growth factor at 1 — the trace is meant to come from the reveal's own fraction — but the candlestick drawing never received it, so reveal.draw drew every candle at once while iOS brought them in left to right. Candles now land one slot at a time off the same fraction, with the slot width keyed to the full count so nothing re-spaces as they arrive.

54b15f6

Draw Android charts at the size they were asked for. Every absolute number a chart takes — plot padding, axis gutters, stroke and wick widths, annotation dot and halo sizes, glow radii, badge and label geometry, marker sizes — is authored in dp, but the Compose canvas measures in pixels and the drawing code used the two interchangeably. On a 3× phone that made all of it a third of its intended size: a 6 dp live dot drew at 2 dp, a 42 dp glow barely left the stroke, and the axis gutter was too narrow to keep labels off the trace. Pointer hit-testing had the same mismatch, since the plot box was measured in dp and compared against a pixel pointer.

The geometry an app lays its own views out with is now reported in dp, matching iOS's points, so an overlay positioned from useChartScrub's geometry and nativeX lands where the chart drew rather than a screen away.

54b15f6

Let an annotation badge cap its rule instead of sitting on it. The badge was placed a default annotation gap below the plot edge, so a stub of the rule stuck out above it and the dashes ran straight through the circle — the glyph read as floating in the plot rather than as the rule's head. It now sits flush with the plot edge, and the rule starts below it: on Android the rule is drawn from under the badge, and on iOS the badge paints the chart's plot (or theme) background behind itself to mask the part it covers. Charts with a transparent plot background keep the previous translucent badge, since there is nothing to mask with.

54b15f6

Honour interaction.dimOpacity on a candlestick chart. It faded the marks on every other form but did nothing here, so reading a candle left the rest of the series at full strength and the read one hard to pick out. Candles either side of the selection now fade back the same way series marks do.

54b15f6

Stop painting canvas text in the browser's serif when the page declares no font.

A web chart reads the font in effect where it sits and hands it to the canvas, which is what makes it match the type around it. When nothing up the tree declares a font, though, that read answers with the user agent's default — a serif — and the chart drew its axis labels in Times beside text that was not.

React Native Web is where this shows: its reset puts no font-family on html or body and gives each <Text> the system stack through a class of its own, so an Expo web app has nothing for a chart to inherit however deeply it looks. Every chart in one rendered its numbers in Times.

The inherited font is now compared against what the browser resolves with no author styles in play, and falls back to system-ui and the platform stack behind it when the two match. A page that does set a font is untouched: inheritance still wins, and --zyplot-font-family and theme.typography.fontFamily still override both.

54b15f6

Things the renderers drew that they were never asked to.

The web entry point imported a stylesheet that only gathers two others with @import, and not every bundler follows those in development — Metro leaves them out, so a chart came up with no styles at all. That loses the layer its placeholder and its plot share: the two stack up instead, and everything an app positions over the plot is measured from a canvas that starts a placeholder's height too low. It now imports the built stylesheets themselves.

Chart.Line put a symbol on every reading. Its own documentation said symbols appear on hover, and the native renderers draw none — a dot per datum is a mark the reader did not ask for. Set seriesStyles[id].symbol and they come back.

A range or text annotation drew nothing at all, because the components that render them were never registered with ECharts. They are now. A reference line's label showed the value it sits on rather than the label given to it, which dropped a trailing zero from a price; and with an 'overlay' axis it printed that number a second time, on top of the axis' own — labelPosition: 'auto' now keeps it at the rule's leading end, away from them.

Scrubbing a candlestick chart left every candle the pointer had passed still lit, because ECharts' highlight adds to a set rather than replacing it. The bloom behind the read mark was a flat fill with a shadow around it, which reads as a box sitting behind the candle however soft its edges are; it is a radial gradient now, which has no edge to read.

A traced entrance ran behind the placeholder, so the plot cross-faded in with the trace already part-drawn — or already finished, depending on which won the race. The marks now wait for the placeholder to go. Its flash was also rebuilt at full strength whenever the data changed, and nothing was left to put it out: a chart that had already made its entrance kept the glow for good.

On Android an overlaid axis reserved a gutter for its labels _and_ kept plotDimensionEndPadding clear of them, so the marks stopped a label's width further from the edge than on iOS. An overlaid axis reserves no gutter — that is what overlaying means.

54b15f6
0.1.1Patch

Add repository metadata and a bundled LICENSE file to both published packages. npm verifies a provenance-signed publish against repository.url, so the missing field left @hzblj/zyplot-core unpublishable.

ab6e55e
0.1.0Minor

First public release.

Cross-platform React charting behind one package and one shared TypeScript contract: ECharts and uPlot on web, Swift Charts on iOS, and Jetpack Compose on Android, with both native renderers reached through a single Expo Module named Zyplot.

51e9ae3