lightweight_charts_rails
TradingView Lightweight Charts for Rails apps that use importmap-rails and Stimulus. The gem vendors the library (plus its only dependency, fancy-canvas), pins both in the importmap, and ships a small Stimulus controller you subclass.
Requirements
- Rails >= 7.1 with
importmap-railsandstimulus-rails. -
turbo-railsis optional; add it for the realtimeturbo_stream.lightweight_chart_*actions below. - No jsbundling support: the JavaScript is served straight from the gem.
- The test suite exercises propshaft. sprockets is expected to work too, since the engine adds the
vendored files to
config.assets.precompile, but that path is untested.
Installation
gem "lightweight_charts_rails"Then register the controller with the generator:
bin/rails generate lightweight_charts_rails:installThis appends to app/javascript/controllers/index.js:
import { LightweightChartController } from "lightweight-charts-rails"
application.register("lightweight-chart", LightweightChartController)If that file doesn't exist yet, the generator prints the two lines instead of creating anything; if it
already registers lightweight-charts-rails, running the generator again is a no-op.
Usage
The lightweight_chart_tag view helper renders the div LightweightChartController hooks onto, translating
Ruby option/series hashes into the JSON Stimulus values it reads:
<%= lightweight_chart_tag(
style: "height: 300px",
options: { layout: { background: { color: "#fff" } } },
series: [
{ type: "Line", name: "price", options: { color: "#2563eb" }, data: @prices }
],
fit_content: true
) %>- Any key named
time, anywhere insideoptionsorseries(including nested indata,markers, etc.), is normalized for you:Time/DateTime/ActiveSupport::TimeWithZonebecome UNIX seconds andDatebecomes"YYYY-MM-DD". So@pricescan be[{ time: record.traded_at, value: record.price }]straight from ActiveRecord, no manual formatting needed. - Extra
html_options(id,class,style,data: { ... }, a block,tag: :section, ...) pass through tocontent_tagas usual. Adata: { controller: "other" }you pass is merged after ours ("lightweight-chart other"), and avalues:hash adds extra Stimulus values, dasherizing underscored keys (values: { chart_id: "abc" }->data-lightweight-chart-chart-id-value="abc"). -
controller:(default"lightweight-chart") lets you target a subclass registered under another identifier; thedata-*-valueattribute names follow it.
Raw data attributes
lightweight_chart_tag is just a thin layer over plain Stimulus data attributes, which still work if you'd
rather build the tag yourself (e.g. from a non-ERB template, or with hand-normalized data):
<div style="height: 300px"
data-controller="lightweight-chart"
data-lightweight-chart-options-value='{"layout": {"background": {"color": "#fff"}}}'
data-lightweight-chart-series-value='[{"type": "Line", "name": "price", "options": {"color": "#2563eb"}, "data": [{"time": "2026-01-01", "value": 100}]}]'>
</div>Options
-
options— chart options passed tocreateChart(autoSize: trueis added unless you set it). Changing it after connect callschart.applyOptions(options). -
series—[{ type, name, options, data, pane, markers, priceLines }].-
typeis one ofLine,Area,Bar,Candlestick,Histogram,Baseline. -
namedefaults toseries0,series1, … in declaration order. -
datais in lightweight-charts' own format (timeas UNIX seconds,"YYYY-MM-DD"or a business day object -- or, through the helper/time normalization, aTime/Dateyou hand it directly). -
paneis the pane index (0is the main pane); series with the samepaneshare a price scale area. Omit it to use the default pane. -
markersis an array ofSeriesMarkeroptions (time,position,color,shape,text, ...), rendered through thecreateSeriesMarkersplugin. -
priceLinesis an array ofcreatePriceLineoptions (price,color,lineWidth,lineStyle,title, ...).
-
-
fit_content— when true, callschart.timeScale().fitContent()after the declared series are (re)built.
Diff-based series updates
Changing the series attribute after connect does not rebuild every series from scratch. Each declared
entry is matched to the previous one by name (or its positional series0/series1/… key):
- Same
typeandpaneas before -> updated in place:applyOptions,setData(replacing the series' full data -- an entry with nodataclears it), and markers/price lines are replaced wholesale (an entry with nomarkersclears an existing markers plugin instead of leaving it stale; the same is true forpriceLines). - New name, different
type, or moved to a differentpane-> the old series is removed and a new one added. - No longer present in the array -> removed.
- A series you added imperatively (via
addSeries/controller.addSeries, not through theseriesvalue) is never touched by this reconciliation, even across value changes.
The chart itself is destroyed on disconnect and on turbo:before-cache.
Subclassing
import { LightweightChartController } from "lightweight-charts-rails"
export default class extends LightweightChartController {
static values = { url: String } // options and series are inherited
async connect() {
super.connect()
const { points } = await (await fetch(this.urlValue)).json()
this.addSeries("Line", {}, { name: "price", data: points })
this.fitContent()
}
}Available on the controller: chart (IChartApi), series (Map name -> ISeriesApi),
addSeries(type, options, { name, data, pane, markers, priceLines }), setData(name, data),
update(name, point), setMarkers(name, markers), removeSeries(name), fitContent(). The framework-free
core is also exported as ChartHost.
Outgoing events
The controller dispatches Stimulus events, bubbling and prefixed with its own identifier (so a subclass
registered as "sub-chart" fires sub-chart:connected, not lightweight-chart:connected):
-
connected— fired at the end ofconnect(), after the declared series (andfitContent, if requested) are applied.detail: { chart, controller }. -
crosshair-move/click— fired fromchart.subscribeCrosshairMove/subscribeClick, subscribed on connect and unsubscribed on disconnect (and before the host is destroyed onturbo:before-cache).detail: { time, logical, point, seriesData }, whereseriesDatais a plain object mapping series name -> data item (translated from lightweight-charts' ownMap<ISeriesApi, data>; a series this controller doesn't know about is skipped).
document.addEventListener("lightweight-chart:crosshair-move", (event) => {
const { time, seriesData } = event.detail
console.log(time, seriesData.price)
})Realtime updates
Incoming DOM events
Independently of any subclassing, the controller listens on its own element for three fixed
CustomEvents (names are not prefixed by the identifier, unlike the outgoing events above) and drives the
chart from them:
| Event | detail |
Effect |
|---|---|---|
lightweight-chart:update |
{ name, point } |
host.update(name, point) |
lightweight-chart:set-data |
{ name, data } |
host.setData(name, data) |
lightweight-chart:set-markers |
{ name, markers } |
host.setMarkers(name, markers) |
document.getElementById("price-chart").dispatchEvent(
new CustomEvent("lightweight-chart:update", { detail: { name: "price", point: { time: 1706745600, value: 101.2 } } })
)Turbo Stream actions
When turbo-rails is loaded, the gem registers three matching turbo_stream actions
(turbo_stream.lightweight_chart_update / _set_data / _set_markers). Each renders a <turbo-stream>
whose <template> holds the (time-normalized) JSON payload; on the client, installTurboStreamActions()
(installed automatically when lightweight-charts-rails is imported) turns that back into the matching
lightweight-chart:* CustomEvent above on every target element -- so a chart reacts the same way whether
the event was dispatched by hand or delivered through Turbo.
<%# app/views/prices/update.turbo_stream.erb %>
<%= turbo_stream.lightweight_chart_update("price-chart", name: "price", point: { time: @price.traded_at, value: @price.value }) %>
<%= turbo_stream.lightweight_chart_set_data("price-chart", name: "price", data: @prices) %>
<%= turbo_stream.lightweight_chart_set_markers("price-chart", name: "price", markers: @markers) %>target (the first argument) can be an id string or, like the built-in turbo_stream actions, any object
dom_id accepts.
Broadcasting over ActionCable
These are plain turbo_stream actions, so they work with Turbo::StreamsChannel.broadcast_action_to (the
method Turbo::Broadcastable's broadcast_*_to helpers call) like any other action. Build the payload with
LightweightChartsRails::TimeNormalizer.normalize -- the same normalization turbo_stream.lightweight_chart_*
applies -- and pass it as content::
Turbo::StreamsChannel.broadcast_action_to(
"prices",
action: :lightweight_chart_update,
target: "price-chart",
content: LightweightChartsRails::TimeNormalizer.normalize(name: "price", point: { time: price.traded_at, value: price.value }).to_json
)This broadcasts the identical <turbo-stream> markup turbo_stream.lightweight_chart_update would render
inline, over the "prices" stream, to every subscriber's matching target element.
Development
bundle install && npm install
bundle exec rake test # Ruby: engine + dummy app
npm test # JavaScript: node --test + jsdom
bundle exec puma -p 3939 test/dummy/config.ru # demo page at http://localhost:3939/Updating the vendored JavaScript
bundle exec rake "lightweight_charts_rails:update[5.2.1]" # version defaults to npm latestThis task lives in tasks/ and is excluded from the packaged gem, so it must be run from a checkout
of this repository, not from an application that depends on the gem.
License
MIT for this gem. lightweight-charts is Apache-2.0 and fancy-canvas is MIT; see THIRD_PARTY_NOTICES.md.