Chart Animations
How we implement smooth transitions in LibreFolio's ECharts-based charts.
Architecture
echartsAnimationConfig.ts ← Centralized config + helpers
├── CHART_ANIMATION_CONFIG ← Timing/easing settings
├── CHART_SET_OPTION_OPTS ← setOption flags (notMerge/replaceMerge)
└── namedPoint(date, value) ← Named data point for shift animation
Two Animation Strategies
1. Dashboard charts (GrowthChart, AllocationHistoryChart)
These use shift animation — when the time period changes, shared dates translate to new positions and new dates appear/disappear smoothly.
Requirements:
xAxis: { type: 'time' }— ECharts understands temporal coordinates- Data as
namedPoint(date, value)→{name: date, value: [date, val]} - Partial
setOptionon data-only updates: each series is sent as{name, data}only (no full option rebuild) - Full
setOptiononly when something the partial update cannot express has changed (see below)
Key insight (from ECharts developers):
ECharts performs shift animation if it recognizes part of the old data in the new data. This matching is done via the
nameproperty on each data point.
// echartsAnimationConfig.ts
export function namedPoint(date: string, value: number | null) {
return {name: date, value: [date, value]};
}
Partial vs Full update pattern (lib/components/dashboard/GrowthChart.svelte, used by the
Dashboard and Broker detail):
// GrowthChart.svelte — renderChart(forceFullXAxisRebuild = false)
const renderedModeKey = viewMode === 'pnl' ? `pnl:${pnlSubmode}` : viewMode;
const masked = shouldMaskAmount();
const needsFullInit =
forceFullXAxisRebuild ||
lastRenderedMode !== renderedModeKey ||
lastRenderedDark !== isDark ||
lastRenderedMasked !== masked;
if (needsFullInit) {
// Axes, tooltip, legend, series: setOption(option, CHART_FULL_UPDATE_OPTS)
applyFullOption(isDark, buildFullSeries(isDark, seriesData), zoomWindow);
} else {
// Data only, {name, data} per series: setOption({...}, CHART_SERIES_UPDATE_OPTS)
updateChartData(activeData, isDark, zoomWindow, false, logicalRange.startDate);
}
| Full rebuild trigger | Why the partial update is not enough |
|---|---|
renderedModeKey |
The mode (eur, pct, pnl) and, in pnl, the submode: the series type changes (line, candlestick, bar), which {name, data} cannot express. |
isDark |
Colours are set once, in the full option. |
masked |
The privacy toggle: ECharts caches the axis labels, so the tick formatter has to run again. The tooltip formatter reads the flag on every hover. |
forceFullXAxisRebuild |
The x-axis leaves its compact layout (a narrow container widening on resize). |
The two flag sets differ on purpose:
CHART_SERIES_UPDATE_OPTS = {notMerge: false, replaceMerge: ['dataZoom']}—seriesmust not be inreplaceMergehere: the partial update sends only{name, data}, and replacing the matched series would drop the type and styles set by the full option, leaving a blank chart.CHART_FULL_UPDATE_OPTS = {...CHART_SET_OPTION_OPTS, replaceMerge: ['series', 'xAxis']}.
A resolution switch (daily ↔ weekly/monthly, chosen from the zoom) goes through the partial path without animation, after hiding the tooltip.
Weekly and monthly points. In the time-axis views (Abs, % and the P&L line submode, whose
daily, weekly and monthly resolutions the code calls the cascade), buildBucketInfos stands each
weekly or monthly point on the last day of its bucket that the history holds, the day its
aggregated value comes from (aggregateLineSeries keeps a bucket's last point): the calendar end
of a whole period, the last day with data of the period still running. The line reaches the latest
data and the axis ends on that day, never on a Sunday or a month end still to come. On a time axis
ECharts maps a dataZoom percent linearly to time over the drawn extent, not to a point index; the
two agree only while the points are evenly spaced, which the running period's point, or a gap in
the history, breaks. So the cascade reads and writes its window in time: getLogicalRangeFromChart
turns the percents into days from the first point (cascadeDayOffsetsOf) and widens the window to
the points at or outside its edges (cascadeIndicesAt), so a partly visible period is never lost;
an edge within 0.1 day of a point (ZOOM_EDGE_SNAP_DAYS) stands on it, which absorbs float and DST
error. buildZoomWindow writes the percents the same way, in days over the drawn span. The ladder
(below) keeps index percents on its category axis, each edge rounded to the nearest bucket as
ECharts snaps it (ladderIndicesAt).
Tooltip dates. The tooltip headers of GrowthChart, AllocationHistoryChart and the broker
lots' LotComparisonChart write every day in the interface locale, never as a raw ISO date:
through formatDay (lib/utils/core/formatAxisDate.ts: 1 Oct 2026 in en-GB, Oct 1, 2026 in
en-US) in the first two, through the chart's own formatLongDate, same format, in the third. On
their time axes, chart.tooltip.valueAt under a weekly or monthly point prints the point's own
day, and a weekly header (chart.tooltip.weekRange) runs from the bucket's first day up to it
(bucketSpanUpTo, priceChartHelpers.ts, as in PriceChartFull and CandlestickChart): a whole
week or month ends on its bucket end, the one still running, or the one the series stops inside,
on its last day with data, never on a day still to come. LotComparisonChart reads that day from
the hovered point (tooltipBucketInfo, lotComparisonChartHelpers.ts). The ladder header names
the whole calendar period instead (below).
Privacy redraw. The render runs inside tick().then(…), where reading a store registers no
dependency, so the render $effect reads shouldMaskAmount() itself: toggling privacy re-runs it,
and the changed masked value forces the full rebuild. The y-axis ticks mask the digits and keep
the sign outside the mask and the k/M suffix inside it. Tooltip amounts go through the chart's
own fmtCurrency (the currency code, then maskFormattedNumber), so the code and the sign stay
readable; the Abs composition rows (assets at cost, returns, capital) print — at zero, masked or
not. The general rule is in ECharts formatters.
P&L submodes
The pnl mode has three submodes (pnlSubmode). The mode and the submode persist per user
(dashboard-growth-mode, dashboard-growth-pnl-submode), shared by Dashboard and Broker detail;
the defaults are eur and line. A restored pct waits for the first completed load: without %
data (hasPctData) the chart shows eur and the % button is disabled, but the stored value is
kept, so the next mount with % data opens on pct. The submode buttons float over the plot's
top-left corner and fold to their icon below a 640 px container (CONTROLS_COMPACT_BELOW_PX),
keeping the label as title and aria-label.
| Submode | Series | x-axis |
|---|---|---|
line |
Total P&L split by sign into two series (zero crossings interpolated), a dashed reference line flat at the first visible day's P&L (__pnlReference__, left out of the legend), and one line per broker when brokerPnlHistory holds two or more (Broker detail passes none) |
Time axis, shift animation |
candles |
One candlestick series of the total P&L, without broker overlay. A gap is ECharts' empty value '-': a null item crashes the candlestick series on a category axis |
Category axis (the ladder) |
income |
Six bar series in three columns per bucket: the income stack (dividends and interest above zero, costs below), deposits, and acquisitions split between new capital and reinvested returns | Category axis (the ladder) |
The ladder (Candles and Income) draws on a category axis, one slot per bucket, so every
candle body and bar gets the same width at any zoom; its labels and separators come from a ladder
plan (growthLadderAxis.ts) instead of the responsive time-axis policy. Each submode keeps a fixed
number of series in a fixed order, which the partial update relies on across zoom and pan.
- Rungs are calendar periods (
growthLadderBuckets.ts):1D;1Wfrom Monday to Sunday;1Mfrom the 1st;3M,6Mand1Yas quarters, halves and years.3Dand2Whave no calendar unit: they are fixed blocks counted from 1970-01-01 and from Monday 1969-12-29, so their edges never move. The caption's unit letter comes fromdatePicker.granularity.*Short. - Axis labels (
planLadderAxis): a label names the start of its bucket's calendar period on the entry's rung (entry.rung), even when the series covers only part of it: data from Thursday 1 January 2026 gets Dec 29 as its first1Wlabel. On the day rungs (1D,3D,1W,2W) a label is the day number, with the month on the first visible label and wherever the month changes, and the two-digit year as well wherever the year changes — Oct 6 · 13 · 20 · 27 · Nov 3 · 10 … Dec 29 · Jan 5 '26 · 12 … — but never the year on the first visible label.1Mprints the month,3Mthe quarter (dashboard.pnlAxisQuarter,Q{quarter}) and6Mthe months it spans joined by an en dash, each with the two-digit year (dashboard.pnlAxisWithYear,{label} ''{year}) on the first visible label and wherever the year changes: Oct '25 · Nov · Dec · Jan '26, Q4 '25 · Q1 '26 · Q2 · Q3, Jul–Dec '25 · Jan–Jun '26.1Yprints the year, 2026. "Changes" compares a bucket with the previous labelled one, so two neighbouring labels never read the same. Dates go throughIntl.DateTimeFormatin the interface locale and both keys are translated, so a quarter reads Q4 '25 in English and T4 25 in Italian. - Label layout: horizontal labels need 8 px between them (
LADDER_LABEL_GAP_PX, widths estimated at 0.6 × font size × characters), and the outermost ones move inward to stay 2 px inside the canvas (LADDER_EDGE_SAFETY_PX). If even one visible label does not fit, all of them turn 45°, never just some. Turned labels are thinned only when they would still overlap, that is when slot × √½ is less than one label row (LADDER_LABEL_ROW_EM): then every second, third… bucket keeps a label, counted back from the most recent one, which keeps its own. In practice only dense Candles get there; an Income slot is always wide enough. A turned label that the canvas edge would cut off is left out (on the left, the next candidate becomes the first label). For the band under the axis,ladderLabelMeasuremeasures the turned labels at 14 px in the chart's font (echarts.format.getTextRect) and gives every label the widest one's box (labelBox), socontainLabelreserves room for it. - Offer (
availableCandleWidths, against the measured plot width): a rung is removed, not disabled, when a candle body would be narrower than 2.5 px (LADDER_MIN_BODY_PX) or, in Income, when each of the three bars would be narrower than 4.5 px (INCOME_MIN_BAR_PX; the bars keep 30 % of the slot between buckets and 10 % of a bar between columns). A rung also needs three buckets, except1D. Income starts at1W, and the offer is never empty: the floor rung stays. - Selection (
reconcileCandleWidth): the first ladder render takes the finest rung offered, and every entry into Income takes1M, or the nearest rung offered (incomeOpeningPending). Otherwise Candles and Income sharecandleWidth. A rung that leaves the offer climbs to the next wider one offered, or to the widest. The width is not persisted, and a zoom never changes it: it only re-plans the axis labels. - Edges of the series: a bucket the series starts inside, or a past bucket it stops inside, is
partialand drawn at half opacity (PARTIAL_BUCKET_OPACITY) in both submodes; otherwise the bucket that has not ended yet iscurrentand drawn whole. The tooltip header prints the rung and the whole calendar period, by design (1M - 1 Oct 2026 → 31 Oct 2026inen-GB), thenchart.tooltip.partialBucketorchart.tooltip.currentBucket(none on a one-day bucket), thenchart.tooltip.valueAtthe last day with data, except in Income, whose bars are sums. Every day goes throughformatDay; a1Dbucket prints only its date. - Candles: the daily OHLC comes from the backend (see
Price Resolver),
requested through
onRequestPnlCandleson entry while none is loaded. A bucket takes the first open, the highest high, the lowest low and the last close of its days that have a candle (ladderCandleMetric); a bucket with none is a gap, whose tooltip printscommon.noData. The disclosure caption (dashboard.pnlCandlesHypotheticalShort) stays on one line under the plot, scrolled byscrollOnOverflowwhen it does not fit, unless the user prefers reduced motion. - Income: a bucket sums its days (
ladderFlowMetric). Both acquisition series are nameddashboard.bookValue, so the legend, deduplicated by name, lists five entries and one click toggles both zones; since ECharts keeps legend state by name, it also toggles the Abs area with the same text. The tooltip lists dividend, interest and their total, then costs, deposit and the purchase value with its two↳halves, each only when non-zero. The four flow series come fromPOST /api/v1/portfolio/report(include_income_history,include_cost_history,include_deposit_history,include_acquisition_funding): the first three scan the transactions of each broker in scope, asset-linked or not, keep their sign, and leave out an amount whose FX conversion fails, listing its pair inmissing_fx_pairs; the funding split comes from the engine's per-broker cash pools, where aBUYspends the returns pool before capital.
2. Detail page charts (LineChart, CandlestickChart, PriceChartFull)
These use instant rendering (animation: false) because:
- Complex segmented series (baseline coloring, stale gradient) create N sub-series with variable count between renders
- ECharts cannot reliably match segments across period changes
- The visual priority is precision, not animation
For these charts, the UX pattern is: keep old data visible until new data arrives (stale-while-revalidate at the store level), then swap instantly.
Asset detail: Prices and Rolling Return
routes/(app)/assets/[id]/+page.svelte keeps the primary series in primaryMode, 'price' or
'calendar-return' (the Prices and Rolling Return buttons). It starts on 'price' on every
mount and is never persisted; setPrimaryMode() stops the measure mode of the panel it leaves.
'price' |
'calendar-return' |
|
|---|---|---|
| Main series | the resolved closes (chartData) |
the ASSET_CALENDAR_ROLLING_RETURN points (calendarReturnView) |
| Signals panel | every definition | allowedSignalTypes = ['asset-comparison']: the other configs stay saved, only hidden; no backend indicator is requested |
PriceChartFull |
line or candlestick, Abs/% toggle, event markers, editMode |
valueUnit="percentage", disableCandlestick, hideViewModeToggle, no event markers |
| Measures | MeasurePanel |
a second MeasurePanel, measurementUnit="percentage-points": Δ pp and Days replace Δ % and Δ%/yr |
| Data editor | its button and panel | button hidden, panel hidden and inert |
The rolling return is a backend signal. loadChartData() sends one
POST /api/v1/assets/prices/query: the main asset's item carries
{signal_code: 'ASSET_CALENDAR_ROLLING_RETURN', params: {window_days}}, with target_currency when
the chart is converted, and each Asset comparison gets an item of its own — include_price: false,
include_events: true, target_currency set to the chart currency — with the same signal request,
so every line uses one window and one currency. A page-local wrapper,
queryAssetPricesBulkWithSignalIsolation(), validates each signal result on its own, so one
malformed result does not discard the response. The plugin is catalog_visible = False
(see Plugin Contract), so no
selector offers it.
calendarReturnWindow.ts turns the Window control into days: presets 1w, 1m, 3m, 1y
are 7, 30, 90 and 365 days, and a custom amount is multiplied by 7, 30 or 365 (weeks, months,
years); the default is 1m. The selection is saved in the asset's chart settings
(calendarReturnWindow, below).
Each point of the answer carries a provenance: its status, reference_target_date, and the price
and FX dates actually used, each with its days_back. PriceChartFull reads it through
mainPointContext and adds up to three tooltip rows to the main line: ↩ the reference target
date (followed by chart.tooltip.valueAt when the reference price is older), 📅 the current price
date when it is older than the point, and 💱 the current and reference FX dates. The plugin leaves
a point empty (missing_reference, invalid_current_price, invalid_reference_price) rather than
estimating it and trims the leading empty points, so each line starts on its own first computable
date. With some empty points the result is partial (undefined_metric_window warning); with no
computable point it is unavailable (undefined_metric).
Asset detail: where the chart state lives
| Layer | Holds |
|---|---|
| Backend database | Asset prices and events, FX rates: the only persisted sources |
Chart settings in localStorage (Where settings live) |
The asset's override asset-<id>, including its calendarReturnWindow and its signals (indicator and comparison configs with their params, order and styles) |
The period in sessionStorage |
The start and end shared by the pages of the tab |
| Page runtime | Signal results, the rolling-return series and their provenance, comparison series, synthetic benchmarks, measures: a reload discards them |
Measure rows and their endpoint results are runtime-only. Each measure's summary DataTable keeps
only its own layout (page size, column widths, order and visibility) under its storageKey
(measure-summary-<id>, asset-calendar-measure-summary-<id>).
Asset detail: page sync and comparison sync
requiredFxPairs lists the FX pairs the page needs to convert into the chart currency: the
asset's, each Asset comparison's, and those of their events' currencies. Each pair has a status
(ok, missing, no-data, partial-gap) that drives the data-quality banners. The toolbar
Sync (handleSync()) opens the page sync modal with the main asset (when it has a provider),
the comparison assets, and every listed pair except the missing ones: a pair is never registered
implicitly. The button is disabled for an asset without a provider and for an archived one.
A comparison card's Sync (handleSyncAsset() → runSyncAsset()) sends, in parallel,
POST /api/v1/assets/prices/sync for the peer and POST /api/v1/fx/currencies/sync for
collectConfiguredComparisonFxSlugs() — the configured pairs between the chart currency and the
peer's currency or its events' currencies. Both use buildComparisonSyncRange()
(charts/loadComparisonData.ts): the chart range, extended back by the window in
'calendar-return' mode, then widened by padSyncRange() (7 days either side, the end capped at
today). In 'price' mode, comparison points the backend could not convert stay in their native
currency (see Price Query);
loadComparisonAssetsData() (same file) drops them and flags the card with _conversionFailed, so
a comparison line never mixes currencies.
Chart Settings and Axis Scales
The FX and Asset charts (list cards, detail pages, and the Chart Settings modal's preview) share one settings store and one set of axis controls.
Where settings live
lib/stores/chartSettingsStore.svelte.ts keeps the settings in localStorage, per user
(lf_<userId>_chartSettingsStore, written 250 ms after the last change). Each chart reads, in
order:
- its own override, keyed by the pair slug for FX and
asset-<id>for an asset: a card's ⚙️ and the detail page's aesthetics and Signals panels all write this one; - its scope's default,
__global_fx__or__global_assets__, written by Settings in the list toolbar; - the base defaults (
DEFAULT_CHART_SETTINGS): area fill, baseline colours, grid lines and stale gradient on, the price axis on Auto and the percentage axis on Include 0, no signals.
Saving a scope default (setGlobalSettings(settings, scope)) deletes every override of that
scope, detail pages included, and leaves the other scope alone. The period is not a chart
setting: dateRangeStore.svelte.ts keeps it in sessionStorage (librefolio_dateRange), shared
by the Dashboard, broker, asset and FX pages of the tab.
Axis rows and ranges
axisScales holds one setting per axis: absolute and percentage for the primary axis,
secondary[key] for the others. The detail pages and the modal build their axis rows from the
primary row of the active view plus collectConfigurableSecondaryAxes() (chartCoreHelpers.ts),
which lists one row per secondary axis that the overlays on screen use, keyed
independent:<axisKey>, volume:<axisKey> or legacy:<index>.
Each row has three modes, resolved by buildPriceYAxis() for the primary axis and
resolveSecondaryAxisScale() for the others:
- Auto: the range of the data shown;
- Include 0: the same range, stretched to zero;
- Custom: the Min and Max typed in, swapped if inverted; an empty field leaves that end to the data.
No plugin bound is applied. The minimum and maximum a backend signal declares on its axis
(RSI's 0–100, for example) reach the row descriptor as defaultMin / defaultMax, but no range
reads them: Auto fits the data.
Min and Max are type="number" inputs with use:numericArrows
(lib/actions/numericArrows.ts: ↑/↓ step by 1, a held key speeds up by powers of ten at round
values, within the field's min/max when set) and the .lf-compact-number-input class
(app.css: 0.75rem tabular figures). Below 768 px the iOS zoom guard
(Mobile CSS) sets every input without .zoom-guard-exempt to 16 px; the
class then only resets the line height.
Responsive Date Axis
lib/components/charts/responsiveXAxis.ts (buildResponsiveXAxisPolicy()) decides how many date
labels fit and how they read, in the interface locale (uiLocale()) unless a locale is passed.
Two neighbouring labels never read the same (pinned in chartCoreHelpers.test.ts).
- Compact or wide: the usable width is the chart width minus
horizontalPadding, 64 px by default. The axis turns compact when that width is under 480 px, or leaves fewer than 24 px per point. A compact axis keeps 2 to 12 labels, one per 48 px on a time axis or per 56 px on a category axis: a time axis gets that count assplitNumber, a category axis with more points anintervalthat spreads them. - Compact labels:
axisLabelturns onhideOverlap,showMinLabelandshowMaxLabel, setsrotate: 0and carries the formatter. A category axis also getsalignMaxLabel: 'right': the forced last label ends at its tick instead of spilling past the chart's right edge on a phone. A time axis does not: ECharts ranks time labels by tick level before hiding overlaps, so a month or year tick next to the edge would win, and the end label, pulled inward, would be the one hidden; its charts make room for the label instead (next bullet). The first label needs no alignment: it spills at most into the y-axis labels, inside the chart. - Right inset (time axis, compact or wide): the axis ends on the last day of the data or of the
zoom, and ECharts centres that day's label on its tick, so half of it overhangs the plot's right
edge.
grid.containLabel: truereserves the labels' height, never that overhang, so on a phone the last label would be cut.timeAxisGridRight()takes the chart'swidth, the axisvalues, thelocale, the grid'sbaseright edge and ameasurefunction, and returns the grid'sright: half the widest label the axis can draw, rounded up, plus 2 px, when that is wider thanbase(px, or a percentage ofwidthas ECharts reads it); otherwisebaseitself, so a desktop keeps its percentage and follows its own width.containLabel: truestays. ECharts 6'souterBoundsMode: 'same'withouterBoundsContain: 'axisLabel'was rejected: it estimates the label room once, beforehideOverlappicks the ticks that survive, so on a narrower plot it cut labels that were whole and lost first labels (andcontainLabel: trueignores theouterBounds*keys). The price is a narrower plot on a phone (about 13–15 px in the dashboard charts, 4–5 px inLotComparisonChart), so sometimes one label fewer;hideOverlapmay still hide the end label when the previous tick sits too close: a missing label, never a cut one. - Inset candidates (
timeAxisLabelCandidates, cached for up to 16 locale-and-range keys): the widest label is taken over every textbuildTimeAxisFormatter(below) can write, compact or wide, on an axis whose data runs from the first to the last ofvalues. These are the two end days in the endpoint format (day and month, plus the two-digit year when the range crosses a calendar year), and the label of each local midnight from the day before the first to the day after the last, since zoom can put the axis edge on any day of the data and ECharts widens a one-day extent by a day on each side: 15 Jul on a day other than the 1st, 2026 on 1 January, both Aug and 1 Aug on another 1st (inen-GB). Past 366 days, every day of a leap year (2024) stands in for the calendar, except 1 January, which adds the year of each real one in range. Measured before the render, the inset is conservative: equal to, or a few px wider than, what ECharts draws.axisLabelMeasure(chart, fontSize)gives themeasure: the widthecharts.format.getTextRect()returns in the font ofaxisLabelFont(chart, fontSize), the chart model'stextStyle.fontFamilyor, before a first option, ECharts 6's own default (sans-serif,Microsoft YaHeion Windows). It is lazy on purpose: the font is resolved at the first measure andecharts.formatread only inside the call, so a test that mocks ECharts withoutformatstill mounts a chart that measures nothing. - Wide labels: no
axisLabeloverrides, only theformatter, which the consumers apply in both modes: ECharts' own labels are English on a time axis and raw ISO dates on a category one. - Time axis (
buildTimeAxisFormatter): a label names the tick ECharts places, not the length of the period, so ticks a week apart read 8 Aug, 15 Aug (inen-GB), never Aug Aug. A day tick reads 15 Jul and 1 January 2026. The 1st of another month reads Aug when the ticks are months (tick level 0), 1 Aug when it is a month boundary among day ticks. A tick off local midnight gets no label: it would repeat a day already on the axis. When compact, with the end labels forced on, the first and last data points keep their day, plus a two-digit year when the range crosses a calendar year; the midnight of a last day that ends mid-day gets no label. - Category axis (
buildCategoryAxisFormatter): a compact axis labels one point everyinterval + 1. Month names stand alone only when every two neighbouring labels fall in different months; otherwise a label reads day and month. The two-digit year appears where it changes from the previous label, and on the first and last point when the data crosses a year. A label off the regular grid (the last point, or one moved by zoom) keeps its day. A wide axis has nointerval: ECharts picks the labels, so each carries what it needs, the month for monthly data, the day otherwise, the year whenever the data crosses one.
The category axis serves PriceChartFull (asset and FX detail, with its CandlestickChart) and
LineChart (the Chart Settings preview and the risk charts; the asset and FX cards draw it
through PriceChartCompact, date axis hidden). The time axis serves GrowthChart in its time-axis
views (the Candles and Income ladders plan their own labels, above), AllocationHistoryChart and
the broker lots charts (LotWacPriceChart, LotComparisonChart and LotGanttChart, which pass
their own padding). When a chart goes back from compact to wide, its consumer rebuilds the whole
option, so compact-only keys such as alignMaxLabel do not linger.
Three of the time-axis charts take the right inset. GrowthChart (base: '4%', 14 px labels)
computes it in its time-axis views only (the ladders keep '4%': their plan keeps the outermost
labels inside the canvas, above), in the full option, in the partial update (new dates are new
labels, so the inset travels with the axis they are drawn on) and on resize, since '4%' stands
for other pixels at another width; the resize applies it at once, not lazily, because
syncPlotGeometry() reads the grid that call lays out. AllocationHistoryChart ('4%', 14 px)
computes it with every option it builds and on resize (lazyUpdate). LotComparisonChart
(base: 18 px, 12 px labels) computes it on its axis date range (xAxisRange, else the series'
own) only when it builds the option: a px base does not follow the width. LotWacPriceChart and
LotGanttChart keep a fixed 18 px (GRID_RIGHT_PX).
Pie Charts (AllocationPieChart)
lib/components/charts/AllocationPieChart.svelte draws the allocation pies of the Dashboard and
Broker detail (AllocationPanel, type and sector tabs) and the sector distribution of the asset
detail page. It uses segment morph animation:
- Data-only updates send just the slices'
data; ECharts matches slices bynameand animates the arcs. mode="type"groups asset types by family (assetTypeFamily, throughallocationHierarchy.ts). As soon as one family on screen contains a subtype, the chart draws two rings (buildAllocationRings,allocationRings.ts): the inner ring holds one arc per family, the outer ring splits the families that have subtypes into their members and gives the others a filler drawn invisibly, so the two rings stay aligned. Without subtypes the single ring is drawn as before;mode="sector"always draws one ring.- With two rings, a data-only update refreshes both series by id (
alloc-base,alloc-outer): updating only the first would leave the outer ring on the previous numbers, with no error. - A full option is built on init, on a dark-mode change, on resize (without animation), and when the set of asset types on screen — the ring layout included — differs from the last full build.
setOptionmerges series by index. When the layout switches between one ring and two (rings !== lastRings), the full option is applied withreplaceMerge: ['series'], so the old ring series do not stay on screen; every other full build merges withnotMerge: falsealone.- Every arc of both rings takes its family's label as its
name, so the legend lists families only and one click hides a family's arcs on both rings. The inner ring shows the family icon only on arcs of at least 18° (minShowLabelAngle); the outer ring captions each member outside with its share, the generic member asdashboard.allocationGeneric("Generic ETF"), and its tooltip adds a↳ <family> <share>%line. - Shades (
shadeForDepth,allocationHierarchy.ts): the generic member keeps the family colour, each subtype is shaded by its rank among the subtypes. With one or two subtypes the lightness walks away from the nearer extreme; from three on, the shades spread on both sides of the base within lightness 10–90, every other one at half saturation.
AllocationPanel persists its view and tab per user (dashboard-allocation-view,
dashboard-allocation-tab), shared with Broker detail; a restored History requests its data on
mount, as the click does. Its History view, AllocationHistoryChart, groups types by the same
families but draws one area per family, without shades; the tooltip lists each family's members,
the generic one as dashboard.allocationGeneric.
Numeric Values (TweenedValue)
The TweenedValue.svelte component interpolates between numbers:
<TweenedValue value={navHeroAmt} format={(v) => formatCurrencyAmountPlain(v, displayCurrency, {showSign: false})} />
- Uses Svelte's
tweened()store withcubicOuteasing - Default duration: 900ms (
durationprop) loadingshows a skeleton pulse instead of the number- Used by
KpiSection(hero values),KpiMetricBar(whennumericValueis set) andRiskMetricCard
A value counts up from 0 when it first appears. A page that already knows its figures — served from
the page cache — calls setTweenHydration(isHydrated) during its init: every TweenedValue below
it that mounts while isHydrated() is true starts at its value, and still tweens on later changes.
The Dashboard and Broker detail pages do this.
KPI Bars (CSS transitions)
Both bars live in lib/components/ui/display/ (see Display blocks):
KpiMetricBar: bar width and marker position animate viaduration-700transitions; its value tweens over 700 msKpiDivergingFlowBar: the left and right bars animate independently (transition-all duration-700)- Color changes:
transition-colors duration-300
Stale-While-Revalidate Pattern
Dashboard loading states are designed to never blank the screen on subsequent visits:
// summaryLoading only true when NO data exists yet
let summaryLoading = $derived(reportLoading && !summary);
When the user changes period: 1. Old data stays visible (summaryLoading = false because summary ≠ null) 2. Backend fetch runs in background 3. New data arrives → reactive updates trigger → charts animate to new values
Configuration Reference
// echartsAnimationConfig.ts
export const CHART_ANIMATION_CONFIG = {
animation: true,
animationDuration: 600, // New elements enter
animationDurationUpdate: 800, // Existing elements update
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
};
export const CHART_SET_OPTION_OPTS = {
notMerge: false, // Enable diffing against previous state
replaceMerge: ['series'], // Replace series (matched by name) with animation
};
DOM Persistence
Chart containers must never be destroyed between data updates:
<!-- ✗ WRONG: container destroyed when loading changes -->
{#if loading}
<Skeleton />
{:else}
<div bind:this={chartContainer}></div>
{/if}
<!-- ✓ CORRECT: container always present, skeleton as overlay -->
<div class="relative">
{#if loading}
<div class="absolute inset-0 z-10"><Skeleton /></div>
{/if}
<div bind:this={chartContainer} class:invisible={loading}></div>
</div>
If the DOM element is destroyed, the ECharts instance is lost and must be recreated from scratch — eliminating any possibility of transition animation.