Close Editor Run Reset Auto Update CJS const lcjs = require('@lightningchart/lcjs')
const {
lightningChart,
Themes,
AxisTickStrategies,
emptyFill,
emptyLine,
defaultAxisIntervalRestrictionsXY,
SolidFill,
SolidLine,
ColorRGBA,
ImageFill,
ImageFitMode,
PalettedFill,
LUT,
ColorPalettes
} = lcjs
// Initialize LightningChart JS
const lc = lightningChart()
const exampleContainer = document.getElementById('chart') || document.body
if (exampleContainer === document.body) {
exampleContainer.style.width = '100vw'
exampleContainer.style.height = '100vh'
exampleContainer.style.margin = '0px'
}
const containerChart1 = document.createElement('div')
exampleContainer.append(containerChart1)
const chart = lc.ChartXY({
container: containerChart1,
legend: {
marginInner: 0,
marginOuter: 5,
padding: 0,
entries: {buttonFillStyle: emptyFill},
visible: false
},
// theme: Themes.darkGold
})
.setTitle('Price Distribution of Diamonds by Carat')
chart.forEachAxis(axis => axis.setIntervalRestrictions(defaultAxisIntervalRestrictionsXY))
containerChart1.style.width = '100%'
containerChart1.style.height = '100%'
containerChart1.style.position = 'relative'
containerChart1.style.overflow = 'hidden'
const series = chart.addPointSeries({
schema: {
carat: { pattern: null },
price: { pattern: null },
clarity_rank: { pattern: null }
}
})
.setName('Clarity')
.setPointSize(2)
.setPointStrokeStyle(emptyLine)
fetch(document.head.baseURI + 'examples/assets/1711/diamonds.json')
.then((r) => r.json())
.then((data) => {
const clarityLookup = {}
data.forEach(item => {
clarityLookup[item.clarity_rank] = item.clarity
})
const colors = [0, 1, 2, 3, 4, 5, 6, 7].map(ColorPalettes.warm(8))
const lutSteps = Object.keys(clarityLookup)
.map(rank => Number(rank))
.sort((a, b) => a - b)
.map(rank => ({
value: rank,
color: colors[rank - 1],
label: clarityLookup[rank]
}))
series.setPointFillStyle(new PalettedFill({
lookUpProperty: 'value',
lut: new LUT({
interpolate: false,
steps: lutSteps
})
}))
series.setDataMapping({ x: 'carat', y: 'price', lookupValue: 'clarity_rank'})
series.appendJSON(data, { whitelist: ['carat', 'price', 'clarity_rank'] })
chart.axisX.setTitle('Carat')
chart.axisY.setTitle('Price ($)')
// Set startup zoom intervals
chart.axisX.setInterval({start: 0.34, end: 3.17})
chart.axisY.setInterval({start: 1200, end: 12000})
const maxInterval = series.getXMax() - series.getXMin()
chart.setCursorFormatting((_, hit, hits) => {
const clarity = clarityLookup[hit.lookupValue];
return [
['Carat', '', hit.x.toFixed(2)],
['Price ($)', '', hit.y.toFixed(0)],
['Clarity', '', clarity]
]
})
// Create second chart overlaid above the first one that is used to control zoom range on the main chart
const overlayContainer = document.createElement('div')
containerChart1.append(overlayContainer)
const overlayChart = lc.ChartXY({
container: overlayContainer,
legend: { visible: false },
// theme: Themes.darkGold
})
.setTitle('')
.setPadding(0)
.setCursorMode(undefined)
.setUserInteractions(undefined)
overlayContainer.style.position = 'absolute'
overlayContainer.style.zIndex = '1'
const strokeColor = chart.getTheme().isDark ? ColorRGBA(255, 255, 255) : ColorRGBA(180, 180, 180)
overlayChart
.setBackgroundFillStyle(emptyFill)
.setSeriesBackgroundFillStyle(new SolidFill({ color: ColorRGBA(0,0,0) }))
.setSeriesBackgroundStrokeStyle(new SolidLine({ thickness: 1, fillStyle: new SolidFill({ color: strokeColor }) }))
overlayChart.engine.setBackgroundFillStyle(emptyFill)
overlayChart.forEachAxis(axis => axis.setThickness(0).setTickStrategy(AxisTickStrategies.Empty).setStrokeStyle(emptyLine))
const overlayRectangleS = overlayChart.addRectangleSeries()
.setAutoScrollingEnabled(false)
const overlayRectangle = overlayRectangleS.add({ x1: 0, x2: 0, y1: 0, y2: 0 })
.setFillStyle(emptyFill)
.setStrokeStyle(new SolidLine({ thickness: 1, fillStyle: new SolidFill({ color: strokeColor }) }))
// Overlay charts axes should have always interval of full data set range
overlayChart.axisX.setInterval({ start: series.getXMin(), end: series.getXMax() })
overlayChart.axisY.setInterval({ start: series.getYMin(), end: series.getYMax() })
// Overlay charts rectangle follows main charts axis range and same other way
let axisSyncOngoing = false
chart.forEachAxis(axis => {
axis.addEventListener('intervalchange', () => {
if (axisSyncOngoing) return
axisSyncOngoing = true
// Adjust point size based on zoom level
const zoomInterval = (chart.axisX.getInterval().end - chart.axisX.getInterval().start) / maxInterval
const newPointSize = zoomInterval < 0.05 ? 6 :
zoomInterval < 0.2 ? 4 :
zoomInterval < 0.5 ? 3 : 2
series.setPointSize(newPointSize)
overlayRectangle.setDimensions({
x1: chart.axisX.getInterval().start,
x2: chart.axisX.getInterval().end,
y1: chart.axisY.getInterval().start,
y2: chart.axisY.getInterval().end
})
axisSyncOngoing = false
})
})
let debounceMoveOverlay
const syncMainChartFromOverlay = () => {
const dimensions = overlayRectangle.getDimensionsTwoPoints()
chart.axisX.setInterval({ start: dimensions.x1, end: dimensions.x2 })
chart.axisY.setInterval({ start: dimensions.y1, end: dimensions.y2 })
}
overlayRectangle.addEventListener('pointerdown', (eventDown) => {
let prev = eventDown
// Find out which part of the rectangle is being dragged
const coordStart = overlayChart.translateCoordinate(eventDown, overlayChart.coordsAxis)
const dimensionsStart = overlayRectangle.getDimensionsTwoPoints()
const calcDist = (x, y) => (coordStart.x - x) ** 2 + (coordStart.y - y) ** 2
const cornerDistances = {
lb: calcDist(dimensionsStart.x1, dimensionsStart.y1),
lt: calcDist(dimensionsStart.x1, dimensionsStart.y2),
rb: calcDist(dimensionsStart.x2, dimensionsStart.y1),
rt: calcDist(dimensionsStart.x2, dimensionsStart.y2),
center: calcDist((dimensionsStart.x1 + dimensionsStart.x2) / 2, (dimensionsStart.y1 + dimensionsStart.y2) / 2)
}
const corner = Object.entries(cornerDistances).reduce((prev, cur) => cur[1] < prev[1] ? cur : prev, ['center', 999999999999999])[0]
const handleMove = (eventMove) => {
const delta = {
x: eventMove.clientX - prev.clientX,
y: eventMove.clientY - prev.clientY
}
const dimensionsBefore = overlayRectangle.getDimensionsTwoPoints()
const pPixels = overlayChart.translateCoordinate({ x: dimensionsBefore.x1, y: dimensionsBefore.y1 }, overlayChart.coordsAxis, overlayChart.coordsRelative)
const pPixelsAfter = { x: pPixels.x + delta.x, y: pPixels.y - delta.y }
const pAxisAfter = overlayChart.translateCoordinate(pPixelsAfter, overlayChart.coordsRelative, overlayChart.coordsAxis)
const deltaAxis = { x: pAxisAfter.x - dimensionsBefore.x1, y: pAxisAfter.y - dimensionsBefore.y1 }
let dimensionAfter
if (corner === 'center') {
dimensionAfter = {
x1: pAxisAfter.x, y1: pAxisAfter.y,
x2: dimensionsBefore.x2 + (pAxisAfter.x - dimensionsBefore.x1),
y2: dimensionsBefore.y2 + (pAxisAfter.y - dimensionsBefore.y1),
}
} else if (corner === 'rt') {
dimensionAfter = {
x1: dimensionsBefore.x1, y1: dimensionsBefore.y1,
x2: dimensionsBefore.x2 + deltaAxis.x, y2: dimensionsBefore.y2 + deltaAxis.y
}
} else if (corner === 'rb') {
dimensionAfter = {
x1: dimensionsBefore.x1, y2: dimensionsBefore.y2,
x2: dimensionsBefore.x2 + deltaAxis.x, y1: dimensionsBefore.y1 + deltaAxis.y
}
} else if (corner === 'lb') {
dimensionAfter = {
x2: dimensionsBefore.x2, y2: dimensionsBefore.y2,
x1: dimensionsBefore.x1 + deltaAxis.x, y1: dimensionsBefore.y1 + deltaAxis.y
}
} else if (corner === 'lt') {
dimensionAfter = {
x2: dimensionsBefore.x2, y1: dimensionsBefore.y1,
x1: dimensionsBefore.x1 + deltaAxis.x, y2: dimensionsBefore.y2 + deltaAxis.y
}
}
if (!dimensionAfter) return
// Prevent moving view outside min/max range
const rangeMaxX = overlayChart.axisX.getInterval()
const rangeMaxY = overlayChart.axisY.getInterval()
if (corner === 'center') {
if (dimensionAfter.x2 > rangeMaxX.end) {
const diff = dimensionAfter.x2 - rangeMaxX.end
dimensionAfter.x1 -= diff
dimensionAfter.x2 -= diff
}
if (dimensionAfter.x1 < rangeMaxX.start) {
const diff = rangeMaxX.start - dimensionAfter.x1
dimensionAfter.x1 += diff
dimensionAfter.x2 += diff
}
if (dimensionAfter.y2 > rangeMaxY.end) {
const diff = dimensionAfter.y2 - rangeMaxY.end
dimensionAfter.y1 -= diff
dimensionAfter.y2 -= diff
}
if (dimensionAfter.y1 < rangeMaxY.start) {
const diff = rangeMaxY.start - dimensionAfter.y1
dimensionAfter.y1 += diff
dimensionAfter.y2 += diff
}
} else {
if (dimensionAfter.x2 !== dimensionsBefore.x2) {
dimensionAfter.x2 = Math.max(dimensionAfter.x2, dimensionAfter.x1 + 0.05 * (rangeMaxX.end - rangeMaxX.start))
}
if (dimensionAfter.y2 !== dimensionsBefore.y2) {
dimensionAfter.y2 = Math.max(dimensionAfter.y2, dimensionAfter.y1 + 0.05 * (rangeMaxY.end - rangeMaxY.start))
}
if (dimensionAfter.x1 !== dimensionsBefore.x1) {
dimensionAfter.x1 = Math.min(dimensionAfter.x1, dimensionAfter.x2 - 0.05 * (rangeMaxX.end - rangeMaxX.start))
}
if (dimensionAfter.y1 !== dimensionsBefore.y1) {
dimensionAfter.y1 = Math.min(dimensionAfter.y1, dimensionAfter.y2 - 0.05 * (rangeMaxY.end - rangeMaxY.start))
}
dimensionAfter.x1 = Math.max(dimensionAfter.x1, rangeMaxX.start)
dimensionAfter.x2 = Math.min(dimensionAfter.x2, rangeMaxX.end)
dimensionAfter.y1 = Math.max(dimensionAfter.y1, rangeMaxY.start)
dimensionAfter.y2 = Math.min(dimensionAfter.y2, rangeMaxY.end)
}
overlayRectangle.setDimensions(dimensionAfter)
if (debounceMoveOverlay) clearTimeout(debounceMoveOverlay)
debounceMoveOverlay = setTimeout(syncMainChartFromOverlay, 500) // 500ms debounce
prev = eventMove
}
const handleUp = (eventUp) => {
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', handleUp)
}
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', handleUp)
})
let isCapturing = false
let suppressLayoutChange = false
let debounceTimer
// Capture screenshot and set it as overlay background
const captureScreenshotToOverlay = () => {
if (isCapturing) return
isCapturing = true
suppressLayoutChange = true
const padding = chart.getPadding()
const title = chart.getTitle()
const cursorMode = chart.getCursorMode()
const xInterval = chart.axisX.getInterval()
const yInterval = chart.axisY.getInterval()
chart.setPadding(0)
chart.setTitle('')
chart.setCursorMode(undefined)
chart.legend.setOptions({ visible: false })
chart.axisX.setThickness(0).fit(false)
chart.axisY.setThickness(0).fit(false)
chart.engine.layout()
requestAnimationFrame(() => {
const screenshotDataUrl = chart.engine.captureFrame(undefined, undefined, true)
const img = new Image();
img.src = screenshotDataUrl
img.onload = () => {
overlayChart.setSeriesBackgroundFillStyle(new ImageFill({ source: img, fitMode: ImageFitMode.Stretch }))
chart.setPadding(padding)
chart.setTitle(title)
chart.setCursorMode(cursorMode)
chart.legend.setOptions({ visible: true })
chart.axisX.setThickness(undefined).setInterval(xInterval)
chart.axisY.setThickness(undefined).setInterval(yInterval)
chart.engine.layout()
suppressLayoutChange = false
setTimeout(() => { isCapturing = false }, 100)
}
})
}
// Sync overlay container size and position on main chart layout changes
chart.addEventListener('layoutchange', event => {
if (suppressLayoutChange) return
overlayContainer.style.left = `${event.margins.left}px`
overlayContainer.style.top = `${event.margins.top}px`
overlayContainer.style.width = `${event.viewportWidth * 0.15}px`
overlayContainer.style.height = `${event.viewportHeight * 0.15}px`
})
// Re-capture screenshot on resize
chart.engine.addEventListener('resize', () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(captureScreenshotToOverlay, 200)
})
requestAnimationFrame(() => {
captureScreenshotToOverlay()
})
}) Scatter Chart with Integrated Zoom Overlay - Editor This high-density scatter plot provides an analysis of 53,940 diamonds, illustrating the relationship between price and carat weight. The data is further categorized by clarity, using a color lookup table to differentiate grades. Distinct vertical clusters at round-number weights (e.g., 1.0ct) reveal significant price jumps, marking where consumer demand for specific milestones creates market premiums.
To maintain context during exploration, an overview chart is positioned above the main plot to display the entire dataset and indicate the current zoom level via a rectangular overlay. This is implemented efficiently by using an ImageFill; a screenshot of the main ChartXY is captured as a data URL and applied directly as the background fill style for the overview series.
const screenshotDataUrl = chart. engine. captureFrame ( undefined , undefined , true )
const img = new Image ( ) ;
img. src = screenshotDataUrl
const fill = new ImageFill ( { source: img } )
overlayChart. setSeriesBackgroundFillStyle ( fill) The background image of the overview chart is updated every time the browser window size changes.
The data used in this example: Tidyverse / Diamonds CSV .