Minor Changes
-
66541ab- Let the crosshair carry a label, so the text above it keeps up with the finger.crosshairStyle.labelstakes 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.labelColorandlabelSizestyle it. 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 foronInteractionto 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- AddfadeToto 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 noheightkeeps an even fill rather than guessing at one. -
66541ab- Addmarker.trail, a selection marker that lights the trace up to the reading.marker.segmentbrightens a windowspansteps 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
dimOpacityfades the whole line at once.marker.traillights from the first datum to the
reading instead and leaves the rest atdimOpacity, on Swift Charts, the Compose canvas and
the web. It takes neitherspannorsize— 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 adimOpacityto 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- Maketransition: 'morph'do something on iOS and Android.ChartTransitionhas 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 byid— 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.transitionstays 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 sameanimationobject an app
shares with its native screens. -
66541ab- Addfillto the series style: a dot-grid pattern, and a baseline other than the plot floor.fillOpacitywas 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.filllives onNativeChartSeriesStylenext toglow, 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 stillfillOpacity— 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
fillalso paints an area underChart.Line, where it is decoration rather
than the quantity.Chart.Areais unchanged: it still fills by default, and afillonly
overrides the pattern and the baseline.
Patch Changes
-
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:
drawGridonly ever consultedyAxis.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.
gridstill 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.containsis half-open besides, and a scrub is clamped to exactlyplotRight— 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 answersonInteractionby 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 thepointerInput
under the finger. A detector restarted mid-gesture is waiting onawaitFirstDownfor 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 withanimation.delayset 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
@Stateon 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 EChartsaxisLabel.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.Candlestickbuilt
its category axis by hand and carried the same bug, including for an overlaid axis that named
nolabelInset; it now takes the shared inset with the rest. -
66541ab- GiveChart.Candlesticka placeholder shaped like a candlestick chart.Chart.Candlestick.SkeletonwasBarChartSkeleton, 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, 2026arriving asb 6, 2026. Android has always
pinned it inside the chart's own bounds; iOS laid it out as afixedSizeoverlay 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.dimOpacityis 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.dimOpacitysays 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.Linewas also handing
EChartsemphasis.focus, which puts the series into its own blur state on hover — and that
state takes the whole series down,areaStyleincluded. 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
dimOpacityis saying it will do the dimming itself, so ECharts' focus
is now switched off when it does. Charts that name nodimOpacityare unaffected and keep
the focus behaviour they had. -
66541ab- LetisLoading={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- Exportfillfrom@hzblj/zyplot/iosand@hzblj/zyplot/androidtoo.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- GiveChart.TimeSeriesthe 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- Honouranimationon every web chart, not five of them.ChartBasePropshas always declared it, andChart.Line,Chart.Area,Chart.Bar,
Chart.StackedBarandChart.Candlestickhave 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, soduration,delayand
easingwent nowhere andenabled: falseturned nothing off. They animated on the
renderer's own defaults: a full second ofcubicOut, 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 amarkPointhas. 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.easingreached the entrance but not the data change: those are two different keys, and
without the second one every update ran on the renderer's owncubicInOuthowever 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 theiridnow, 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.labelsplaces 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- Stopreveal.fadefrom painting the stroke out again on the web.A fade entrance starts the line at
lineStyle.opacity: 0and 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 ownstartOpacitybehindhasPlayed;'fade'now does the same.
This only ever affected charts that asked forreveal.fadeexplicitly — a chart with no
animation.revealnever 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
throughsetOption— 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 unclampedease-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
todimOpacity, 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.scrubOpacityalso 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 nodashis 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
markLinedefaults 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 whatChart.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, andChart.TimeSeriespainted a stretched grid and giant axis labels sliding
out of the card with no line in sight. They now sit withChartitself, reached by the same
import that reaches a chart, which no tree shake can drop.That stylesheet now arrives in the
basecascade 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.flexor.hiddenfrom here beat the app's owndark:blockand
min-[821px]:hiddenon 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. Inbasethese lose to everything an app writes, while a page whose
only stylesheet is this one renders exactly as before. -
Updated dependencies []:
- @hzblj/zyplot-core@0.3.0