diff --git a/docs/README.skills.md b/docs/README.skills.md index 0c7643e3c..2f1cc5af0 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -272,6 +272,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [latchshot-page-capture](../skills/latchshot-page-capture/SKILL.md)
`gh skills install github/awesome-copilot latchshot-page-capture` | Use this skill when a user needs a screenshot, website thumbnail, full-page capture, or PDF of a public HTTP(S) webpage saved as a local artifact through Latchshot, including report, QA, archive, and social-preview workflows. Do not use it for private or authenticated pages, raw HTML, scraping or extraction, arbitrary browser actions, CAPTCHA or anti-bot bypass, or local-file capture. | `scripts/latchshot.mjs` | | [lean-comments](../skills/lean-comments/SKILL.md)
`gh skills install github/awesome-copilot lean-comments` | Audits, writes, and refines maintained first-party source-code comments and declaration-level documentation across languages. Use when adding, editing, reviewing, cleaning up, or auditing comments, doc comments, docstrings, TODO, FIXME, NOTE, suppressions, or other source commentary, including while modifying code and deciding whether a comment is warranted at all. Defaults to no comment unless it preserves meaningful, non-obvious information that cannot reasonably be recovered from the code or nearby repository context. | None | | [legacy-circuit-mockups](../skills/legacy-circuit-mockups/SKILL.md)
`gh skills install github/awesome-copilot legacy-circuit-mockups` | Generate breadboard circuit mockups and visual diagrams using HTML5 Canvas drawing techniques. Use when asked to create circuit layouts, visualize electronic component placements, draw breadboard diagrams, mockup 6502 builds, generate retro computer schematics, or design vintage electronics projects. Supports 555 timers, W65C02S microprocessors, 28C256 EEPROMs, W65C22 VIA chips, 7400-series logic gates, LEDs, resistors, capacitors, switches, buttons, crystals, and wires. | `references/28256-eeprom.md`
`references/555.md`
`references/6502.md`
`references/6522.md`
`references/6C62256.md`
`references/7400-series.md`
`references/assembly-compiler.md`
`references/assembly-language.md`
`references/basic-electronic-components.md`
`references/breadboard.md`
`references/common-breadboard-components.md`
`references/connecting-electronic-components.md`
`references/emulator-28256-eeprom.md`
`references/emulator-6502.md`
`references/emulator-6522.md`
`references/emulator-6C62256.md`
`references/emulator-lcd.md`
`references/lcd.md`
`references/minipro.md`
`references/t48eeprom-programmer.md` | +| [lightweight-charts-react-components](../skills/lightweight-charts-react-components/SKILL.md)
`gh skills install github/awesome-copilot lightweight-charts-react-components` | lightweight-charts-react-components recipes for TradingView lightweight-charts in React. Candlestick, line, and area series; volume and indicator panes; realtime WebSocket updates; infinite history on scroll; crosshair OHLC legends and tooltips; trade markers and price lines; synchronized charts; numeric options and yield-curve axes. Use when adding or fixing a financial or trading chart in a React app, or when the user mentions lightweight-charts, TradingView charts in React, or this package. | `LICENSE`
`references/history-and-range-selection.md`
`references/hover-legends-and-tooltips.md`
`references/market-data-and-live-updates.md`
`references/numeric-axes-and-chart-types.md`
`references/performance-comparison.md`
`references/price-volume-and-indicators.md`
`references/session-overlays-and-primitives.md`
`references/synchronized-charts.md`
`references/trade-markers-and-price-levels.md` | | [linkedin-post-formatter](../skills/linkedin-post-formatter/SKILL.md)
`gh skills install github/awesome-copilot linkedin-post-formatter` | Format and draft compelling LinkedIn posts using Unicode bold/italic styling, visual separators, structured sections, and engagement-optimized patterns. USE FOR: draft LinkedIn post, format text for LinkedIn, create social media post, write thought leadership post, convert content to LinkedIn format, LinkedIn carousel text, Unicode bold italic formatting. | `references/unicode-charmap.md` | | [lsp-setup](../skills/lsp-setup/SKILL.md)
`gh skills install github/awesome-copilot lsp-setup` | Enable code intelligence (go-to-definition, find-references, hover, type info) for any programming language by installing and configuring an LSP server for Copilot CLI. Detects the OS, installs the right server, and generates the JSON configuration (user-level or repo-level). Use when you need deeper code understanding and no LSP server is configured, or when the user asks to set up, install, or configure an LSP server. | `references/java-jdtls.md`
`references/lsp-servers.md` | | [macos-golden-gate-design](../skills/macos-golden-gate-design/SKILL.md)
`gh skills install github/awesome-copilot macos-golden-gate-design` | Design, implement, review, or refactor native macOS 27 Golden Gate interfaces in SwiftUI or AppKit. Use when a Mac app must match Apple's current macOS 27 UI kit and restrained Liquid Glass language: integrated windows, full-height sidebars, native toolbars, flat lists, tables, and forms, clear pane boundaries, accessible materials, and removal of unnecessary nested cards, framed panels, or glassmorphism. Also use for Figma macOS 27 design-to-code work and audits of interfaces that feel too boxed. | `references/design-patterns.md`
`references/sources.md`
`references/swiftui-implementation.md` | diff --git a/skills/lightweight-charts-react-components/LICENSE b/skills/lightweight-charts-react-components/LICENSE new file mode 100644 index 000000000..9cf106272 --- /dev/null +++ b/skills/lightweight-charts-react-components/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/lightweight-charts-react-components/SKILL.md b/skills/lightweight-charts-react-components/SKILL.md new file mode 100644 index 000000000..f0afcc07d --- /dev/null +++ b/skills/lightweight-charts-react-components/SKILL.md @@ -0,0 +1,154 @@ +--- +name: lightweight-charts-react-components +description: lightweight-charts-react-components recipes for TradingView lightweight-charts in React. Candlestick, line, and area series; volume and indicator panes; realtime WebSocket updates; infinite history on scroll; crosshair OHLC legends and tooltips; trade markers and price lines; synchronized charts; numeric options and yield-curve axes. Use when adding or fixing a financial or trading chart in a React app, or when the user mentions lightweight-charts, TradingView charts in React, or this package. +license: MIT +metadata: + author: ukorvl + version: "1.0" + source: "https://github.com/ukorvl/lightweight-charts-react-components/tree/0368191127568e48aef6f7bda2ff3900e8e24756/skills/lightweight-charts-react-components" +--- + +# lightweight-charts-react-components guide + +## Quick Start + +Let the components manage chart and series creation and cleanup. Import components +and ref types from `lightweight-charts-react-components`; import native data, option, +and event types from `lightweight-charts`. + +1. Check versions against the compatibility table below. Read declared ranges + from the app's `package.json`, then the installed versions from the `version` + field of `node_modules//package.json` for + `lightweight-charts-react-components`, `lightweight-charts`, `react`, and + `react-dom`. If the library is not installed, follow + [Installation](#installation). Ensure chart rendering runs in the browser, + within the app's client boundary. +2. For a basic static chart, start from the minimal chart below. Otherwise, read the + reference matching the requested behavior. Adapt its feed and UI; preserve its + data contract, component nesting, and API readiness handling. +3. Choose one data owner: pass the complete dataset through `data` for React-owned + updates, or use `reactive={false}` and the series API for frequent ticks. + +### Installation + +Install the wrapper together with its `lightweight-charts` peer dependency, using +the project's package manager. React and React DOM must already be installed. + +```sh +npm install lightweight-charts-react-components lightweight-charts +``` + +### Minimal chart + +```tsx +import type { LineData, UTCTimestamp } from "lightweight-charts"; +import { + Chart, + LineSeries, + TimeScale, + TimeScaleFitContentTrigger, +} from "lightweight-charts-react-components"; + +// Data must be sorted ascending by time with unique times (UTC seconds here). +export function SimpleLineChart({ data }: { data: LineData[] }) { + return ( + // autoSize follows the container, which needs a nonzero height. + + + + + + + ); +} +``` + +## Examples and snippets (reference files) + +Read the reference matching the user's outcome. Each contains a complete TypeScript +recipe, data and integration contracts, reasons for the architecture, failure modes, +and acceptance checks. Combine references when a task spans several outcomes; do +not load every reference for a simple chart. + +| User request | Reference | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| WebSocket candles, updating the current bar, stale history, excessive chart renders | [Market data and live updates](references/market-data-and-live-updates.md) | +| Older history on scroll, viewport jumps, symbol/interval changes, late API responses | [History and range selection](references/history-and-range-selection.md) | +| Candles plus volume, moving averages, oscillator panes, independent price scales | [Price, volume, and indicators](references/price-volume-and-indicators.md) | +| OHLC legend, hover details, missing data, tooltip positioning | [Hover legends and tooltips](references/hover-legends-and-tooltips.md) | +| Executions and signals, stop/target levels, click-to-place price lines | [Trade markers and price levels](references/trade-markers-and-price-levels.md) | +| Compare assets with different prices, fixed-baseline returns, toggle comparisons | [Performance comparison](references/performance-comparison.md) | +| Separate charts that share scrolling, zooming, and a crosshair | [Synchronized charts](references/synchronized-charts.md) | +| Trading-session shading, pane backgrounds, custom drawing that follows zoom | [Session overlays and primitives](references/session-overlays-and-primitives.md) | +| Options strikes, yield maturities, choosing a numeric or custom horizontal axis | [Numeric axes and chart types](references/numeric-axes-and-chart-types.md) | + +If no reference matches the request, tell the user that the skill has no recipe +for it. Then build the solution from the public components and the upstream +`lightweight-charts` API, applying the Quick Start rules and the edge cases below. + +### Skill compatibility + +The recipes are verified against the reference versions below. These are the +tested baseline, not the latest available releases. + +If an installed version differs from the reference version, compare it with the +compatibility range. Inside the range, recipes apply as written. Outside it, +tell the user about the mismatch before writing code, because breaking API +changes can affect the recipes, and verify every API the recipe uses against +the installed package. + +| Dependency or environment | Reference version | Compatibility range and checks | +| ------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lightweight-charts-react-components` | `2.6.1` | Recipe target: `>=2.6.1 <3`. Other versions require checking installed exports, chart API refs, and reactive data behavior before adapting a recipe. | +| `lightweight-charts` | `5.2.1` | Declared peer: `>=5.0.8 <6`. | +| React and React DOM | `19.2.6` | Declared peers: `>=18.2 <20`. Keep `react` and `react-dom` on matching versions and use the application's existing React instance. | +| Node.js | — | Package engines: `>=18.14.0`. The app's build tools can require a newer version. | +| Module system | ESM | ESM `import` entry only; there is no CommonJS `require` entry. | +| Rendering | Browser | Charts need the DOM and canvas. Render them on the client, never during server rendering. | + +## Common edge cases + +- **Chart sizing:** Put DOM styles on `containerProps` and give the chart a + nonzero height. +- **Required ancestors:** Headless chart children need a chart wrapper ancestor. + `Markers`, `PriceLine`, and `SeriesPrimitive` belong inside their series; + `PriceScale`, `Watermark`, and `PanePrimitive` require a `Pane`; + `TimeScaleFitContentTrigger` requires a `TimeScale`. +- **API refs and readiness:** Use `ref.current?.api()` for the native API and + `containerRef` for the div. For `Chart`, use `ChartApiRef`. + `.api()` can initially return null; follow the recipe's readiness step before + imperative setup. +- **Viewport resets:** To preserve scroll/zoom, keep the chart's key stable and + live/history updates out of fit-trigger dependencies. Fit on initial load or a + deliberate reset. See [range handling](references/history-and-range-selection.md). +- **Data updates:** Use `data` for React-owned updates, or `reactive={false}` and the + series API when per-tick React renders are measurably costly. Avoid mixing both. + Reactive `data` is always the complete dataset. With wrapper 2.6.1 and + Lightweight Charts 5.2.1, `series.data()` reconstructs candle and line objects; + the wrapper's earlier-item reference comparison therefore sends multi-bar tail + updates through `setData()`, even when React keeps earlier references stable. + Keep data immutable, but use the chart-owned `reactive={false}` recipe and + explicit `series.update(bar)` when incremental writes are required. See + [live updates](references/market-data-and-live-updates.md). + +## Source + +Adapted from [ukorvl/lightweight-charts-react-components](https://github.com/ukorvl/lightweight-charts-react-components/tree/0368191127568e48aef6f7bda2ff3900e8e24756/skills/lightweight-charts-react-components) +at commit `0368191127568e48aef6f7bda2ff3900e8e24756`. Original author: ukorvl. +The [MIT license](LICENSE) and all nine reference recipes are retained. Local +corrections in [live updates](references/market-data-and-live-updates.md) reject +malformed timestamps and explain the tested versions' reactive-update limitation; +[session overlays](references/session-overlays-and-primitives.md) clarifies the +primitive update callback. +The other seven references and MIT notice are unchanged. This catalog copy adds +source attribution and operational boundaries; it does not imply TradingView +endorsement. + +Recipes generate application code and can change dependencies. Follow the user's +requested scope and the host's approval policy. Feed access belongs to the app's +separately authorized adapter; keep credentials out of client code. Trade markers +are visual annotations, not order execution or server-side alerts. Use synthetic +data for local checks and report browser/feed behavior that remains untested. diff --git a/skills/lightweight-charts-react-components/references/history-and-range-selection.md b/skills/lightweight-charts-react-components/references/history-and-range-selection.md new file mode 100644 index 000000000..c34bb56f8 --- /dev/null +++ b/skills/lightweight-charts-react-components/references/history-and-range-selection.md @@ -0,0 +1,315 @@ +# History and range selection + +Use this when a user scrolls left to fetch older bars, selects a different symbol +or interval, or switches between preset viewing windows. These are distinct +operations: adding history should preserve the view, changing dataset identity +should reset it, and choosing a window should change only the visible range when +the required data is already loaded. + +## Recipe: prepend older history without losing the viewed bars + +This is a **single-series intraday chart**. `initialData` is a nonempty ascending, +unique array of UTC-second line points. `loadOlder(before, signal)` returns +normalized ascending points and an explicit `hasMore`. Its cursor is exclusive; +the recipe also discards overlap defensively. Mount with a new key for a new +dataset. The adapter owns transport, authentication, and response validation. + +Use chart-owned data for this recipe so `setData()` and range restoration occur +together. Capturing the range when a request starts would undo any panning the +user performs while waiting; capture it immediately before applying the response. + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + IChartApi, + LineData, + LogicalRange, + Time, + UTCTimestamp, +} from "lightweight-charts"; +import { + Chart, + LineSeries, + TimeScale, + type ChartApiRef, + type SeriesApiRef, +} from "lightweight-charts-react-components"; + +type Point = LineData; +type HistoryPage = { data: Point[]; hasMore: boolean }; +type LoadOlder = (before: UTCTimestamp, signal: AbortSignal) => Promise; + +export function PaginatedHistory({ + initialData, + loadOlder, +}: { + initialData: Point[]; + loadOlder: LoadOlder; +}) { + const [seed] = useState(() => initialData.map(point => ({ ...point }))); + const rows = useRef(seed); + const chartRef = useRef>(null); + const seriesRef = useRef>(null); + const alive = useRef(false); + const inFlight = useRef(null); + const exhausted = useRef(false); + const failed = useRef(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const requestOlder = useCallback(async () => { + const series = seriesRef.current?.api(); + const scale = chartRef.current?.api()?.timeScale(); + const first = rows.current[0]; + if (!alive.current || !series || !scale || !first) return; + if (inFlight.current || exhausted.current || failed.current) return; + + // A ref locks synchronously; React loading state alone allows duplicate + // requests when several range events arrive before a render. + const controller = new AbortController(); + inFlight.current = controller; + setLoading(true); + setError(null); + try { + const page = await loadOlder(first.time, controller.signal); + if (!alive.current || controller.signal.aborted) return; + + const byTime = new Map(); + for (const point of page.data) { + if (point.time < first.time) byTime.set(point.time, { ...point }); + } + const older = [...byTime.values()].sort((a, b) => a.time - b.time); + if (older.length === 0 && page.hasMore) { + throw new Error("History cursor made no progress"); + } + + if (older.length > 0) { + const visible = scale.getVisibleLogicalRange(); + rows.current = [...older, ...rows.current]; + series.setData(rows.current); + if (visible) { + // Each inserted point shifts the old point's index by one. Translate + // both bounds to keep the same old bars and the same zoom width. + scale.setVisibleLogicalRange({ + from: visible.from + older.length, + to: visible.to + older.length, + }); + } + } + exhausted.current = !page.hasMore; + } catch (cause) { + if (alive.current && !controller.signal.aborted) { + // Stop event-driven retries after a failure. Let the user retry once + // instead of turning a drag into a loop of failed network requests. + failed.current = true; + setError(cause instanceof Error ? cause.message : "History load failed"); + } + } finally { + if (inFlight.current === controller) inFlight.current = null; + if (alive.current) setLoading(false); + } + }, [loadOlder]); + + const onRange = useCallback( + (range: LogicalRange | null) => { + if (!range) return; + const bars = seriesRef.current?.api()?.barsInLogicalRange(range); + if (bars && bars.barsBefore < 20) void requestOlder(); + }, + [requestOlder] + ); + + useEffect(() => { + alive.current = true; + let frame = 0; + const initializeView = () => { + const series = seriesRef.current?.api(); + const scale = chartRef.current?.api()?.timeScale(); + if (!series || !scale) { + frame = requestAnimationFrame(initializeView); + return; + } + // Start near the tail. No fit trigger runs after a page is prepended. + scale.setVisibleLogicalRange({ + from: Math.max(0, seed.length - 80), + to: seed.length - 1 + 5, + }); + onRange(scale.getVisibleLogicalRange()); + }; + initializeView(); + return () => { + alive.current = false; + cancelAnimationFrame(frame); + inFlight.current?.abort(); + }; + }, [seed, onRange]); + + return ( +
+

{loading ? "Loading older history…" : "Scroll left for older history"}

+ {error && ( + + )} + + + + +
+ ); +} +``` + +Keep `loadOlder` stable for one dataset. Changing it restarts the view initialization +effect; changing the dataset should instead remount with a new key. The cancellation +check protects against transports that resolve even after abort. + +The index translation above assumes each prepended point adds one chart timeline +position. With several series, logical indices refer to their **union of times**. +Build one aligned timeline for all series and calculate the number of newly added +timeline positions. Do not shift by the page length of an arbitrary indicator. +If a live feed also owns the tail, merge against the current history at response +time and apply updates through the same data owner; do not let the captured request +snapshot overwrite newer ticks. + +## Recipe: symbol or interval replacement with stale-response protection + +Here the API returns a complete normalized OHLC snapshot. Copy this as a separate +module. A selection ID labels each response, and a chart is mounted only for the +matching result. This prevents showing the prior symbol under the new label and +gives the new dataset its own initial fit. + +```tsx +import { useEffect, useState } from "react"; +import type { CandlestickData, UTCTimestamp } from "lightweight-charts"; +import { + CandlestickSeries, + Chart, + TimeScale, + TimeScaleFitContentTrigger, +} from "lightweight-charts-react-components"; + +type Interval = "1h" | "1d"; +type Selection = { symbol: string; interval: Interval }; +type Snapshot = CandlestickData[]; +type LoadSnapshot = (selection: Selection, signal: AbortSignal) => Promise; + +export function InstrumentHistory({ loadSnapshot }: { loadSnapshot: LoadSnapshot }) { + const [selection, setSelection] = useState({ + symbol: "ASSET_A", + interval: "1d", + }); + // The effect depends on these values: re-clicking the current interval creates a + // new selection object but must not refetch or reset the view. + const { symbol, interval } = selection; + const id = `${symbol}:${interval}`; + const [result, setResult] = useState<{ id: string; data: Snapshot } | null>(null); + const [error, setError] = useState<{ id: string; message: string } | null>(null); + const [retry, setRetry] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let active = true; + setResult(null); + setError(null); + void loadSnapshot({ symbol, interval }, controller.signal).then( + data => { + if (active) setResult({ id, data }); + }, + cause => { + if (active) { + setError({ + id, + message: cause instanceof Error ? cause.message : "Load failed", + }); + } + } + ); + return () => { + active = false; + controller.abort(); + }; + }, [id, symbol, interval, loadSnapshot, retry]); + + const current = result?.id === id ? result : null; + return ( +
+ + + + {error?.id === id ? ( + + ) : !current ? ( +

Loading {id}…

+ ) : current.data.length === 0 ? ( +

No history for {id}

+ ) : ( + + + + + + + )} +
+ ); +} +``` + +Supply a stable `loadSnapshot` adapter. If a same-symbol refresh should preserve +zoom, keep the chart mounted, replace the full data array, and avoid a new fit +trigger. The recipe intentionally resets on selection because the data identity +and interval changed. + +For a preset such as “last month” over already-loaded data, keep the series data +intact and call `timeScale.setVisibleRange({ from, to })` with matching time types, +or update `TimeScale`'s `visibleRange` prop after initialization. For “last 100 +bars,” use logical bounds. Do not pass visible-range bounds and a fit trigger for +the same interaction: the fit trigger can overwrite the requested range. +Changing `timeVisible` formats an axis; it does not aggregate daily bars into +hourly bars. The data adapter must return the chosen granularity. + +## Acceptance checks + +- Drag left rapidly: one request is in flight, overlapping timestamps are not + duplicated, and the bars visible at response time stay visible after prepend. +- Pan again before the response arrives; the eventual response preserves that + later view. Keep scrolling until `hasMore` is false; requests stop. +- Simulate an error: the chart remains usable and retry is explicit. Unmount + while fetching: no response updates a removed chart. +- Select A, B, and a new interval rapidly. Return A's response last and verify + that only the final selection renders. Cover empty snapshots and retry. +- Click the interval that is already selected: no request starts and the chart + keeps its zoom and scroll position. diff --git a/skills/lightweight-charts-react-components/references/hover-legends-and-tooltips.md b/skills/lightweight-charts-react-components/references/hover-legends-and-tooltips.md new file mode 100644 index 000000000..9c4f787ed --- /dev/null +++ b/skills/lightweight-charts-react-components/references/hover-legends-and-tooltips.md @@ -0,0 +1,191 @@ +# Hover legends and tooltips + +Use this for “show OHLC under the cursor,” a persistent latest-bar legend, or a +floating details box. The chart renders a canvas; the wrapper supplies event +handlers but no tooltip or legend UI. Keep the HTML details alongside the chart +in a positioned application container. + +## Architecture choices + +- Use `onCrosshairMove` on `Chart`; the wrapper owns subscription and cleanup. + Do not also call `subscribeCrosshairMove()` for the same handler. +- Look up `param.seriesData` by the **native series API** returned by + `seriesRef.current.api()`. The map is not keyed by wrapper refs or string IDs. +- The map can omit a series at a given time. Check presence and the appropriate + shape (`close` for candles, `value` for lines/histograms) before reading values. +- Keep selection by time and read current data from the application snapshot. + Updating the currently hovered candle then updates the legend even when the + pointer stays still. Comparing only timestamps would leave stale OHLC values. +- Ignore identical selection/position updates. Native option/data updates can + emit crosshair events; always creating new React state can feed back into + another options update and cause a render loop. Selection equality is safe + here because displayed OHLC comes from current data, not cached event values. +- `param.point` is local to the chart pane, not the viewport. This recipe is for + one pane; add each pane's DOM offset for tooltips over a multi-pane chart. + +## Recipe: live OHLC legend and clamped tooltip + +`data` is normalized, ascending, unique UTC-second candles; the initial snapshot +is ready before mounting. The application owns live updates through the complete +array. Copy this as one component module. The details are React text, so symbol +labels and values are not interpolated into `innerHTML`. + +```tsx +import { useCallback, useMemo, useRef, useState } from "react"; +import type { CandlestickData, MouseEventParams, UTCTimestamp } from "lightweight-charts"; +import { + CandlestickSeries, + Chart, + TimeScale, + TimeScaleFitContentTrigger, + type SeriesApiRef, +} from "lightweight-charts-react-components"; + +type Hover = { time: UTCTimestamp; x: number; y: number }; +const tooltipWidth = 190; +const tooltipHeight = 96; +const chartOptions = { autoSize: true, timeScale: { timeVisible: true } }; + +export function CandleDetails({ + symbol, + data, +}: { + symbol: string; + data: CandlestickData[]; +}) { + const seriesRef = useRef>(null); + const containerRef = useRef(null); + const [hover, setHover] = useState(null); + const byTime = useMemo(() => new Map(data.map(bar => [bar.time, bar])), [data]); + const selected = hover ? byTime.get(hover.time) : undefined; + // Leaving the plot, missing data, and an empty dataset are distinct states. + const legend = selected ?? data[data.length - 1]; + + const onCrosshairMove = useCallback((param: MouseEventParams) => { + const series = seriesRef.current?.api(); + const container = containerRef.current; + const point = param.point; + if ( + !series || + !container || + !point || + typeof param.time !== "number" || + point.x < 0 || + point.y < 0 || + point.x > container.clientWidth || + point.y > container.clientHeight + ) { + setHover(null); + return; + } + const bar = param.seriesData.get(series); + if (!bar || !("close" in bar)) { + setHover(null); + return; + } + // Clamp in the same coordinate system as the positioned chart container. + // If the card is narrower than the tooltip, CSS also shrinks its width. + const width = Math.min(tooltipWidth, container.clientWidth); + const x = Math.max(0, Math.min(point.x + 12, container.clientWidth - width)); + const y = Math.max(0, Math.min(point.y + 12, container.clientHeight - tooltipHeight)); + const next = { time: param.time as UTCTimestamp, x, y }; + // Native updates can emit the same crosshair event without pointer movement. + // Preserve state identity in that case; current data still refreshes OHLC. + setHover(previous => + previous?.time === next.time && previous.x === x && previous.y === y + ? previous + : next + ); + }, []); + + const describe = (bar: CandlestickData) => + `O ${bar.open.toFixed(2)} · H ${bar.high.toFixed(2)} · L ${bar.low.toFixed(2)} · C ${bar.close.toFixed(2)}`; + + return ( +
+

+ {symbol}: {legend ? describe(legend) : "No data"} +

+
+ + + + + + + {hover && selected && ( +
+ {symbol} +
{new Date(selected.time * 1000).toISOString().slice(0, 16)} UTC
+
{describe(selected)}
+
+ )} +
+
+ ); +} +``` + +The plain 2-decimal formatter is appropriate only for assets with that precision. +Use the same precision as the series' `priceFormat` for other tick sizes. For +business dates, format the date fields directly; a numeric timestamp is epoch +seconds and requires multiplying by 1000 before constructing a `Date`. + +This recipe falls back to the latest candle when the hovered series has no +data. If the user wants to distinguish a gap from leaving the chart, keep an +explicit “gap” state and show “No bar at this time” instead. A timezone or locale +formatter changes labels, never the underlying bar timestamps. + +## Adaptation: several series or a fixed legend + +For several lines, keep refs indexed by a stable application series ID. For each +series, retrieve its native API, then read `param.seriesData.get(api)`. Display +`—` when the map has no point; do not show another asset's value at the same +logical index. Derive labels/colors from the application's ID mapping, not the +order in which map entries happen to appear. + +For a fixed legend, omit the floating box and position updates. Keep the selected +time and derive the displayed values from the latest data, as above. For very +high pointer-event rates, batch the HTML update with `requestAnimationFrame`; +cancel the pending frame on cleanup. Avoid writing React state for every series +separately within one crosshair callback. + +For a tooltip in a scrollable portal attached to `document.body`, convert the +chart-local coordinate using `getBoundingClientRect()` and the portal's positioning +model. Do not reuse the local x/y unchanged as page coordinates. With multiple +panes, `param.paneIndex` identifies the source pane; compute its offset or put the +tooltip inside the appropriate pane container. + +## Acceptance checks + +- Hover first/last bars and whitespace; leave the canvas and price axis. Missing + map entries never throw, the floating box disappears, and the legend falls back. +- Update a hovered candle at the same timestamp without moving the pointer. + Its OHLC changes in the legend and tooltip. +- Hover near all four edges at a narrow width. The box remains inside the chart + and does not prevent dragging, scrolling, or crosshair movement. +- Do not suppress legend updates when only the hovered timestamp matches: a live + same-bar revision keeps the time but changes the values. Guard against a + missing lookup entry before reading candle fields. +- For multi-series adaptation, test different calendars and missing points. diff --git a/skills/lightweight-charts-react-components/references/market-data-and-live-updates.md b/skills/lightweight-charts-react-components/references/market-data-and-live-updates.md new file mode 100644 index 000000000..a932316c3 --- /dev/null +++ b/skills/lightweight-charts-react-components/references/market-data-and-live-updates.md @@ -0,0 +1,283 @@ +# Market data and live updates + +Use this for a quote screen or trading terminal that loads OHLC history and then +receives bar snapshots from a socket. The usual pains are duplicate timestamps, +milliseconds treated as seconds, stale candles after a refetch, and a chart that +keeps zooming out while the user reads history. + +## Decide who owns the complete history + +| Data flow | Implementation | Reason | +| ------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------- | +| Modest update rate; other React UI needs the history | Full array in `data`, default `reactive` | One source of truth for the chart and application | +| A snapshot, refetch, prepend, trim, or historical correction | Replace the full array; optionally `alwaysReplaceData` | The chart must match the supplied history exactly | +| Frequent ticks; chart does not need to render React per tick | `reactive={false}`, then `ref.current.api()?.update(bar)` | The chart owns ongoing updates; React owns its lifetime | + +Reactive `data` always means the **entire desired dataset**. Passing `[latestBar]` +replaces history. The wrapper's incremental path compares earlier item references +against `series.data()`. With wrapper **2.6.1** and Lightweight Charts **5.2.1**, +that API reconstructs candle and line objects, so multi-bar tail replacements and +appends use `setData()` even when React preserves earlier object references. +Treat the React-owned recipe as complete-history replacement on this baseline. +For frequent incremental writes, use the chart-owned recipe below with +`reactive={false}` and explicit `series.update(bar)`. + +`reactive={false}` still calls `setData(data)` during initialization. It ignores +subsequent data prop changes. Do not combine imperative ticks with a reactive +snapshot that can overwrite them. + +## Normalize the feed at the boundary + +This adapter expects **complete candle snapshots**, not individual trades. Build +OHLC aggregation upstream if the feed contains trade prices. `timestampMs` is the +bar-open instant in UTC milliseconds. A payload that revises the same bar appears +later in the response and wins. Invalid data is rejected rather than plotted. + +```ts file=market-data.ts +import type { CandlestickData, UTCTimestamp } from "lightweight-charts"; + +export type Candle = CandlestickData; +export type WireCandle = { + timestampMs: number; + open: number | string; + high: number | string; + low: number | string; + close: number | string; +}; + +export function normalizeCandle(row: WireCandle): Candle { + // Reject runtime coercion of null, strings, and booleans to epoch seconds. + if (typeof row.timestampMs !== "number") { + throw new Error("Invalid candle timestamp"); + } + const seconds = row.timestampMs / 1000; + const prices = [row.open, row.high, row.low, row.close].map(value => { + // The wire type is not a runtime guarantee, and Number() turns null and blank + // strings into 0, which would pass the range checks below. + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim() !== "") return Number(value); + throw new Error("Missing OHLC price"); + }); + const [open, high, low, close] = prices; + if ( + !Number.isSafeInteger(seconds) || + !prices.every(Number.isFinite) || + low > Math.min(open, close) || + high < Math.max(open, close) || + low > high + ) { + throw new Error("Invalid candle snapshot"); + } + // The brand expresses validated epoch seconds; it does not perform conversion. + return { time: seconds as UTCTimestamp, open, high, low, close }; +} + +export function normalizeHistory(rows: WireCandle[]): Candle[] { + const byTime = new Map(); + for (const row of rows) { + const candle = normalizeCandle(row); + byTime.set(candle.time, candle); + } + return [...byTime.values()].sort((a, b) => a.time - b.time); +} + +export function upsertLatest(history: Candle[], bar: Candle): Candle[] { + const last = history[history.length - 1]; + if (!last || bar.time > last.time) return [...history, bar]; + if (bar.time === last.time) return [...history.slice(0, -1), bar]; + // This tail-only path rejects late history. Reconcile corrections separately + // into a complete snapshot, then replace data instead of appending them. + return history; +} +``` + +Keep daily business dates as `YYYY-MM-DD` or `BusinessDay` when the source is a +calendar-day series. Do not convert those dates through the browser's local time +zone. An intraday feed should consistently use UTC seconds. Do not mix time +representations or infer seconds versus milliseconds from an arbitrary threshold. + +## Recipe: React-owned live candles + +Save the adapter above as `market-data.ts`, then this component as +`LiveCandles.tsx`. The subscription adapter delivers normalized complete candles +and returns an unsubscribe function. It must cover the snapshot-to-subscription +gap using the provider's cursor/replay protocol. It must also reject stale same-bar +revisions using the provider's sequence number when one exists; timestamp order +alone cannot order revisions of one candle. + +```tsx file=LiveCandles.tsx +import { useEffect, useState } from "react"; +import { + CandlestickSeries, + Chart, + TimeScale, + TimeScaleFitContentTrigger, +} from "lightweight-charts-react-components"; +import { upsertLatest, type Candle } from "./market-data"; + +export type SubscribeBars = (receive: (bar: Candle) => void) => () => void; + +export function LiveCandles({ + initialData, + subscribe, +}: { + initialData: Candle[]; + subscribe: SubscribeBars; +}) { + // Mount with a key containing symbol + interval + snapshot revision when the + // dataset identity changes. initialData is intentionally a mount-time seed. + const [data, setData] = useState(() => initialData.map(bar => ({ ...bar }))); + + useEffect(() => { + let active = true; + const unsubscribe = subscribe(bar => { + if (active) setData(previous => upsertLatest(previous, { ...bar })); + }); + return () => { + active = false; + unsubscribe(); + }; + }, [subscribe]); + + return ( +
+ + + + {/* A stable trigger fits once. Every tick must preserve user zoom. */} + + + +
+ ); +} +``` + +The wrapper itself defaults only `addDefaultPane` to `false`; it does **not** +enable `autoSize`. Set `autoSize: true` explicitly and give the wrapper a nonzero +height. Width can follow its parent. Flex/grid cards often need `minWidth: 0` on +the card to allow shrinking. Use `containerRef` for the DOM node and the chart +`ref` for its API wrapper; these are different references. + +For a Next.js App Router integration, put `"use client"` at the top of the chart's +client module. If its surrounding setup still cannot prerender the chart, load +that module through a client-side dynamic boundary with `ssr: false`. Do not access +`window` or create chart APIs during server rendering. See the +[Next.js client-only loading guidance](https://nextjs.org/docs/app/guides/lazy-loading#skipping-ssr) +for placement of that boundary. + +## Recipe: chart-owned ticks + +Use this alternative when the chart must consume many snapshots without a React +render for each one. The subscription contract remains the same. The chart and +series refs expose wrapper objects: the native APIs are returned by `.api()`. +A non-null wrapper ref can still contain a null API until initialization finishes. + +```tsx +import { useEffect, useRef, useState } from "react"; +import type { IChartApi, Time, UTCTimestamp } from "lightweight-charts"; +import { + CandlestickSeries, + Chart, + type ChartApiRef, + type SeriesApiRef, +} from "lightweight-charts-react-components"; +import type { Candle } from "./market-data"; +import type { SubscribeBars } from "./LiveCandles"; + +export function ImperativeCandles({ + initialData, + subscribe, +}: { + initialData: Candle[]; + subscribe: SubscribeBars; +}) { + const chartRef = useRef>(null); + const seriesRef = useRef>(null); + // The seed stays stable; mount with a new key for another dataset identity. + const [seed] = useState(() => initialData.map(bar => ({ ...bar }))); + + const fitted = useRef(false); + + useEffect(() => { + let active = true; + let frame = 0; + let unsubscribe: (() => void) | undefined; + + const startWhenReady = () => { + if (!active) return; + const chart = chartRef.current?.api(); + const series = seriesRef.current?.api(); + if (!chart || !series) { + frame = requestAnimationFrame(startWhenReady); + return; + } + // Fit after initialization, then leave the viewport to the user. + if (!fitted.current) { + fitted.current = true; + chart.timeScale().fitContent(); + } + // After a resubscribe the series can hold newer bars than the seed, and + // update() throws on a bar older than the series tail. + const current = series.data(); + let lastTime = current[current.length - 1]?.time as UTCTimestamp | undefined; + unsubscribe = subscribe(bar => { + if (!active || (lastTime !== undefined && bar.time < lastTime)) return; + series.update({ ...bar }); + lastTime = bar.time; + }); + }; + startWhenReady(); + return () => { + active = false; + cancelAnimationFrame(frame); + unsubscribe?.(); + }; + }, [subscribe]); + + return ( + + + + ); +} +``` + +Keep `subscribe` stable: each new function closes and reopens the feed. + +For explicit “Live” controls, call `chartRef.current?.api()?.timeScale().scrollToRealTime()` +from the control handler. `fitContent()` shows the entire dataset; it does not mean +“follow the last bar.” Upstream `shiftVisibleRangeOnNewBar` normally shifts only +when the last bar is visible. For controlled follow mode, turn automatic shifting +off and scroll after accepted updates only when follow mode is enabled. + +## Failure modes and acceptance checks + +- Rebuild the full snapshot when older bars are corrected. Tail-only examples + deliberately ignore older updates; reconnect recovery must replace history. +- Pause drawing by stopping/buffering feed application if desired. Toggling + `reactive` does not stop the socket or give an imperative chart a replay buffer. +- A trim or a batch append may call `setData()`; do not mutate earlier objects in + place and expect the wrapper to detect that mutation. +- Avoid cloning the complete dataset on every render (for example `data.map(...)` + in JSX): it allocates unnecessarily and can retrigger the reactive data effect. + Preserving references does not bypass the baseline's `setData()` limitation + above. Use the chart-owned recipe when frequent full replacements are costly. +- Confirm initial data is sorted and unique; a same-time update replaces exactly + one bar; a later-time update adds one; an older-time update follows the chosen + correction policy. Pan left, update, and verify the user's viewport remains. +- In the chart-owned recipe, change `subscribe` after live bars arrive and replay + an older bar: nothing throws, the bar is ignored, and the viewport does not move. +- Change symbol/interval and verify the old subscription is disposed and no old + bars enter the new chart. Check a narrow card and a client-rendered route for a + nonzero canvas size. diff --git a/skills/lightweight-charts-react-components/references/numeric-axes-and-chart-types.md b/skills/lightweight-charts-react-components/references/numeric-axes-and-chart-types.md new file mode 100644 index 000000000..65af9381a --- /dev/null +++ b/skills/lightweight-charts-react-components/references/numeric-axes-and-chart-types.md @@ -0,0 +1,276 @@ +# Numeric axes and chart types + +Use this for an options surface slice over strikes, a payoff profile over +underlying prices, a yield curve over maturities, or a custom horizontal scale. +The misleading part is that upstream series call the x field `time` even when +the horizontal item is a **number representing a strike or maturity**. + +## Choose the chart constructor from the horizontal domain + +| Horizontal domain | Wrapper | Series/type contract | +| -------------------------------------------- | ----------------------- | -------------------------------------------------- | +| Dates or UTC time | `Chart` | `Time`, including UTC-second timestamps | +| Strike / underlying price | `OptionsChart` | `number`, e.g. `LineData` | +| Time to maturity in one numeric unit | `YieldCurveChart` | `number`; only `LineSeries` and `AreaSeries` | +| A genuinely different horizontal behavior | `CustomChart` | Explicit `IHorzScaleBehavior` | +| Existing dates with a different label format | Usually `Chart` options | A formatter may be enough; no new domain is needed | + +Do not convert strikes into `UTCTimestamp` just to satisfy `Chart` typings. +For yield curves, decide whether one x unit is a month or a year and keep every +dataset and formatter consistent. All numeric x values must be finite, ascending, +and unique. A refreshed curve is normally a full snapshot, not an appended tick. + +## Recipe: implied volatility by strike + +The pricing/market-data layer supplies volatility **in percentage points**, so +24.5 means 24.5%, not 0.245. This component visualizes those observations; it does +not price options or calculate volatility. Different expiries can be represented +by separate line series when they use the same strike domain. + +```tsx +import type { DeepPartial, LineData, PriceChartOptions } from "lightweight-charts"; +import { + LineSeries, + OptionsChart, + TimeScale, + TimeScaleFitContentTrigger, +} from "lightweight-charts-react-components"; + +const strikeOptions = { + autoSize: true, + // Built-in horizontal ticks use precision. timeFormatter formats the + // horizontal crosshair label; the series controls the vertical value format. + localization: { + precision: 0, + timeFormatter: (strike: number) => `$${strike.toFixed(0)}`, + }, +} satisfies DeepPartial; + +export function VolatilitySmile({ + data, + expiryId, +}: { + data: LineData[]; + expiryId: string; +}) { + return ( + + + data={data} + alwaysReplaceData + options={{ + color: "#2563eb", + priceLineVisible: false, + priceFormat: { + type: "custom", + minMove: 0.01, + formatter: (value: number) => `${value.toFixed(2)}%`, + }, + }} + /> + > + + + + ); +} + +export const exampleSmile: LineData[] = [ + { time: 80, value: 31.2 }, + { time: 90, value: 26.5 }, + { time: 100, value: 24.5 }, + { time: 110, value: 25.7 }, + { time: 120, value: 29.1 }, +]; +``` + +`expiryId` changes dataset identity and resets the chart's initial view. Routine +quote refreshes keep it stable and replace data without fitting each refresh. +Choose strike precision from the instrument; whole-dollar labels here are merely +the sample domain. Numeric x ticks are whole strikes, and the horizontal crosshair +label includes the currency prefix. A payoff chart uses the same wrapper with y +values and a formatter representing payoff currency instead of volatility. + +Do not use `localization.priceFormatter` to label strikes: it overrides the +**vertical** price-scale formatter, including a series custom price formatter. +The built-in numeric horizontal behavior uses `localization.precision` for ticks +and `localization.timeFormatter` for the horizontal crosshair label. Formatting +every horizontal tick with a prefix requires an appropriate custom behavior. + +## Recipe: current and previous yield curves + +Both datasets use **months to maturity** for x and percentage points for y. The +wrapper configures upstream yield-curve semantics; it is not a date-time chart +with labels renamed. The two lines share a scale so their levels are comparable. + +```tsx +import type { DeepPartial, LineData, YieldCurveChartOptions } from "lightweight-charts"; +import { + LineSeries, + TimeScale, + TimeScaleFitContentTrigger, + YieldCurveChart, +} from "lightweight-charts-react-components"; + +const yieldOptions = { + autoSize: true, + localization: { priceFormatter: (value: number) => `${value.toFixed(2)}%` }, + yieldCurve: { + startTimeRange: 0, + minimumTimeRange: 360, + formatTime: (months: number) => (months < 12 ? `${months}M` : `${months / 12}Y`), + }, +} satisfies DeepPartial; + +export function YieldComparison({ + current, + previous, + curveId, +}: { + current: LineData[]; + previous: LineData[]; + curveId: string; +}) { + return ( + + + data={current} + alwaysReplaceData + options={{ color: "#2563eb", priceLineVisible: false }} + /> + + data={previous} + alwaysReplaceData + options={{ color: "#94a3b8", lineStyle: 2, priceLineVisible: false }} + /> + > + + + + ); +} + +export const exampleYields: LineData[] = [ + { time: 1, value: 4.8 }, + { time: 6, value: 4.5 }, + { time: 12, value: 4.1 }, + { time: 60, value: 3.7 }, + { time: 120, value: 3.6 }, + { time: 360, value: 3.8 }, +]; +``` + +`YieldCurveChart` accepts only line and area series through the wrapper's runtime +guard. Do not add candle, bar, histogram, or custom series to this chart. The +minimum 360-month span is an explicit display choice here; adapt it to the +product's intended tenor domain. Negative yield values remain legitimate y data; +do not clamp them to zero. + +## Recipe: reuse upstream time behavior for quarterly labels + +This is a custom behavior example when an application chooses to package its +quarterly formatting as an upstream horizontal-scale behavior. For simple label +changes, ordinary `timeScale.tickMarkFormatter` and `localization.timeFormatter` +on `Chart` are smaller. Extend the default behavior instead of reimplementing +sorting, time conversion, caching, and tick generation. + +```tsx +import { useState } from "react"; +import { defaultHorzScaleBehavior } from "lightweight-charts"; +import type { LineData, LocalizationOptions, TickMark, Time } from "lightweight-charts"; +import { + CustomChart, + LineSeries, + TimeScale, + TimeScaleFitContentTrigger, +} from "lightweight-charts-react-components"; + +function quarterLabel(time: Time): string { + const date = + typeof time === "number" + ? new Date(time * 1000) + : typeof time === "string" + ? new Date(`${time}T00:00:00Z`) + : new Date(Date.UTC(time.year, time.month - 1, time.day)); + return `Q${Math.floor(date.getUTCMonth() / 3) + 1} ${date.getUTCFullYear()}`; +} + +const DefaultTimeBehavior = defaultHorzScaleBehavior(); +function isTime(time: unknown): time is Time { + return ( + typeof time === "number" || + typeof time === "string" || + (typeof time === "object" && + time !== null && + "year" in time && + typeof time.year === "number" && + "month" in time && + typeof time.month === "number" && + "day" in time && + typeof time.day === "number") + ); +} + +class QuarterBehavior extends DefaultTimeBehavior { + override formatTickmark( + mark: TickMark, + _localization: LocalizationOptions