Close Editor Run Reset Auto Update CJS const lcjs = require('@lightningchart/lcjs')
const {
lightningChart,
Themes,
PalettedFill,
emptyLine,
LUT,
ColorRGBA,
SolidLine,
ImageFill,
SolidFill,
synchronizeAxisIntervals,
PieChartTypes,
emptyFill,
DataSet,
PointShape,
} = lcjs
const exampleContainer = document.getElementById('chart') || document.body
if (exampleContainer === document.body) {
exampleContainer.style.width = '100vw'
exampleContainer.style.height = '100vh'
exampleContainer.style.margin = '0px'
}
exampleContainer.style.display = 'grid'
;((exampleContainer.style.gridTemplateColumns = 'repeat(5, 1fr)'),
(exampleContainer.style.gridTemplateRows = 'repeat(4, 1fr)'),
(exampleContainer.style.overflow = 'hidden'))
const lc = lightningChart()
// Shared data set
const dataSet = new DataSet({
schema: {
x: { pattern: null },
y: { pattern: null },
PM25_ugm3: { pattern: null },
value: { pattern: null },
},
}).setMaxSampleCount(500_000)
// 3D chart
const chartContainer1 = document.createElement('div')
exampleContainer.append(chartContainer1)
const chart3D = lc
.Chart3D({
container: chartContainer1,
// theme: Themes.darkGold,
})
.setTitle('3D Visualization of PM2.5 Concentration Along Route')
.setTitleMargin({ top: 0 })
chartContainer1.style.width = '100%'
chartContainer1.style.height = '100%'
chartContainer1.style.gridColumnStart = '1'
chartContainer1.style.gridColumnEnd = '4'
chartContainer1.style.gridRowStart = '1'
chartContainer1.style.gridRowEnd = '4'
const theme = chart3D.getTheme()
const bgColor =
theme.chart3DSeriesBackgroundFillStyle.fillType == 'radial-gradient'
? theme.isDark
? ColorRGBA(10, 10, 10)
: theme.chart3DBackgroundFillStyle.stops[1].color
: ColorRGBA(6, 3, 12)
const colors = {
bg: bgColor,
transparent: ColorRGBA(255, 255, 255, 0.8),
grey: ColorRGBA(200, 200, 200, 125),
bad: theme.examples.badGoodColorPalette[0],
neutral: theme.examples.badGoodColorPalette[1],
good: theme.examples.badGoodColorPalette[2],
}
chart3D.engine.setBackgroundFillStyle(emptyFill)
chart3D.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
const axisX3D = chart3D.axisX.setTitle('Longitude')
const axisY3D = chart3D.axisY.setTitle('PM2.5')
const axisZ3D = chart3D.axisZ.setTitle('Latitude')
const series3D = chart3D
.addLineSeries()
.setName('PM2.5 (µg/m³)')
.setEffect(true)
.setStrokeStyle((stroke) => stroke.setThickness(4))
.setDataSet(dataSet, { x: 'x', y: 'PM25_ugm3', z: 'y' })
// ChartXY with map as background
const chartContainer2 = document.createElement('div')
exampleContainer.append(chartContainer2)
const chart2D = lc
.ChartXY({
container: chartContainer2,
legend: { visible: false },
// theme: Themes.darkGold,
})
.setTitle('Mobile Sensor Measurement Locations Along Route')
.setUserInteractions(undefined)
.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
chart2D.engine.setBackgroundFillStyle(emptyFill)
chartContainer2.style.width = '100%'
chartContainer2.style.height = '100%'
chartContainer2.style.gridColumnStart = '4'
chartContainer2.style.gridColumnEnd = '6'
chartContainer2.style.gridRowStart = '1'
chartContainer2.style.gridRowEnd = '4'
const axisX2D = chart2D.axisX.setTitle('Longitude')
chart2D.axisY.dispose()
const axisY2D = chart2D.addAxisY({ opposite: true }).setTitle('Latitude').setMarginAfterTicks(10)
const series2D = chart2D
.addPointLineSeries()
.setName('Route')
.setEffect(false)
.setPointStrokeStyle(emptyLine)
.setPointFillStyle(
new PalettedFill({
lookUpProperty: 'value',
lut: new LUT({
steps: [
{ value: 0, color: colors.bad },
{ value: 100, color: colors.transparent },
],
}),
}),
)
.setPointSize(10)
.setPointShape(PointShape.Triangle)
.setStrokeStyle(new SolidLine({ thickness: 6, fillStyle: new SolidFill({ color: colors.grey }) }))
.setDataSet(dataSet, { x: 'x', y: 'y', lookupValue: 'value' })
synchronizeAxisIntervals(chart3D.axisX, chart2D.axisX)
synchronizeAxisIntervals(chart3D.axisZ, chart2D.axisY)
// Add map image as chart background
const mapImage = new Image()
mapImage.crossOrigin = ''
mapImage.src = new URL(document.head.baseURI).origin + new URL(document.head.baseURI).pathname + 'examples/assets/1715/dublin-map.png'
mapImage.onload = () => {
chart2D.setSeriesBackgroundFillStyle(new ImageFill({ source: mapImage }))
// Maintain static aspect ratio of chart area
const chartPadding = 5
const targetAspectRatio = mapImage.height / mapImage.width
const updateChartAspectRatio = () => {
const chartBounds = chart2D.engine.container.getBoundingClientRect()
const chartSizePx = {
x: Math.ceil(chartBounds.width - 2 * chartPadding),
y: Math.ceil(chartBounds.height - 2 * chartPadding),
}
const curAspectRatio = chartSizePx.y / chartSizePx.x
if (curAspectRatio < targetAspectRatio) {
// Add horizontal chart padding to maintain Map picture aspect ratio
const targetAxisWidth = chartSizePx.y / targetAspectRatio
const horizontalPadding = Math.max(chartSizePx.x - targetAxisWidth, 0)
chart2D.setPadding({ left: horizontalPadding / 2, right: horizontalPadding / 2, top: chartPadding, bottom: chartPadding })
} else if (curAspectRatio > targetAspectRatio) {
// Add vertical chart padding to maintain Map picture aspect ratio
const targetAxisHeight = chartSizePx.x * targetAspectRatio
const verticalPadding = Math.max(chartSizePx.y - targetAxisHeight, 0)
chart2D.setPadding({ top: chartPadding, bottom: verticalPadding / 2, left: chartPadding, right: chartPadding })
}
}
updateChartAspectRatio()
window.addEventListener('resize', updateChartAspectRatio)
}
// Gauge and Pie charts
const avgGaugeRanges = [
{ start: 0, color: colors.good },
{ start: 15, color: colors.neutral },
{ start: 35, color: colors.bad },
]
const peakGaugeRanges = [
{ start: 0, color: colors.good },
{ start: 35, color: colors.neutral },
{ start: 75, color: colors.bad },
]
const sliceFillStyles = [new SolidFill({ color: colors.neutral }), new SolidFill({ color: colors.good })]
const chartContainer3 = document.createElement('div')
exampleContainer.append(chartContainer3)
const gaugeAvg = lc
.LinearGauge({
container: chartContainer3,
orientation: 'vertical',
// theme: Themes.darkGold,
})
.setTitle('Average PM2.5')
.setColorInterpolation(true)
.setRanges(avgGaugeRanges)
.setValueAnimation(false)
.setValueLabelFont((font) => font.setSize(24))
chartContainer3.style.width = '100%'
chartContainer3.style.height = '100%'
chartContainer3.style.gridColumn = '1'
chartContainer3.style.gridRow = '4'
gaugeAvg.engine.setBackgroundFillStyle(emptyFill)
gaugeAvg.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
const chartContainer4 = document.createElement('div')
exampleContainer.append(chartContainer4)
const gaugeMax = lc
.LinearGauge({
container: chartContainer4,
orientation: 'vertical',
// theme: Themes.darkGold,
})
.setTitle('Peak PM2.5')
.setColorInterpolation(true)
.setRanges(peakGaugeRanges)
.setValueAnimation(false)
.setValueLabelFont((font) => font.setSize(24))
chartContainer4.style.width = '100%'
chartContainer4.style.height = '100%'
chartContainer4.style.gridColumn = '2'
chartContainer4.style.gridRow = '4'
gaugeMax.engine.setBackgroundFillStyle(emptyFill)
gaugeMax.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
const chartContainer5 = document.createElement('div')
exampleContainer.append(chartContainer5)
const pie = lc
.Pie({
container: chartContainer5,
legend: { visible: false },
type: PieChartTypes.LabelsInsideSlices,
// theme: Themes.darkGold,
})
.setTitle('Exposure Breakdown')
chartContainer5.style.width = '100%'
chartContainer5.style.height = '100%'
chartContainer5.style.gridColumn = '3'
chartContainer5.style.gridRow = '4'
pie.setSliceFillStyle((index) => sliceFillStyles[index % sliceFillStyles.length])
pie.engine.setBackgroundFillStyle(emptyFill)
pie.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
.setSliceStrokeStyle(new SolidLine({ thickness: 0.75, fillStyle: new SolidFill({ color: colors.transparent }) }))
.setCursorFormatting((_, hit, hits) => {
return [[`${hit.category}: ${hit.value} %`]]
})
const chartContainer6 = document.createElement('div')
exampleContainer.append(chartContainer6)
const gaugeTime = lc
.LinearGauge({
container: chartContainer6,
orientation: 'horizontal',
// theme: Themes.darkGold,
})
.setTitle('Exposure >15 µg/m³ (s)')
.setBackgroundFillStyle(new SolidFill({ color: colors.bg }))
.setValueFormatter((value) => value.toFixed(2))
.setValueLabelFont((font) => font.setSize(24))
chartContainer6.style.width = '100%'
chartContainer6.style.height = '100%'
chartContainer6.style.gridColumn = '4'
chartContainer6.style.gridRow = '4'
gaugeTime.engine.setBackgroundFillStyle(emptyFill)
const timerContainer = document.createElement('div')
exampleContainer.append(timerContainer)
timerContainer.style.backgroundColor = colors.bg.toRGBAString()
timerContainer.style.width = '100%'
timerContainer.style.height = '100%'
timerContainer.style.gridColumn = '5'
timerContainer.style.gridRow = '4'
timerContainer.style.display = 'flex'
timerContainer.style.flexDirection = 'column'
timerContainer.style.justifyContent = 'center'
timerContainer.style.padding = '20px'
timerContainer.style.boxSizing = 'border-box'
timerContainer.style.gap = '15px'
const applyFrame = (element) => {
element.style.boxSizing = 'border-box'
element.style.padding = '12px 16px'
element.style.border = '1px solid #ffffff26'
element.style.borderRadius = '6px'
element.style.backgroundColor = theme.isDark ? '#ffffff0a' : '#ffffff7b'
element.style.color = theme.isDark ? '#e0e0e0' : '#1a1a1a'
element.style.fontFamily = theme.chartXYTitleFont.family
element.style.fontSize = '14px'
element.style.fontWeight = '500'
element.style.boxShadow = '0 2px 4px #00000033'
}
const timeDiv = document.createElement('div')
applyFrame(timeDiv)
timerContainer.appendChild(timeDiv)
const distanceDiv = document.createElement('div')
applyFrame(distanceDiv)
timerContainer.appendChild(distanceDiv)
// Add manual cursors to charts
chart3D.setCursorMode(undefined)
chart2D.setCursorMode(undefined)
const cursor3D = chart3D.addCursor()
const cursor2D = chart2D.addCursor()
const hideCursor = () => {
cursor3D.setVisible(false)
cursor2D.setVisible(false)
}
// Display cursor at given x coordinate and solve nearest for all series in both charts
const displayCursorAt = (info) => {
if (info.z === undefined) {
const solveResults = chart2D
.getSeries()
.map((series) => series.getCursorEnabled() && series.solveNearest({ x: info.x, y: 0 }))
.filter((solve) => !!solve)
if (solveResults.length > 0) {
solveResults[0].x = info.x
solveResults[0].y = info.y
solveResults[0].cursorPosition.pointMarker.x = info.x
solveResults[0].cursorPosition.pointMarker.y = info.y
const sample = { x: info.x, y: info.sample.PM25_ugm3, z: info.y }
const translation = chart3D.translateCoordinate(sample, chart3D.coordsAxis, chart3D.coordsRelative)
cursor3D
.setVisible(true)
.setPosition({
axisLocation: { x: sample.x, y: sample.y, z: sample.z },
resultTableScale: chart3D.coordsRelative,
resultTable: translation,
})
.setResultTable((rt) =>
rt.setContent([
[{ text: 'Concentration', font: { weight: 'bold' }, rowFillStyle: new SolidFill({ color: colors.bg }) }],
['PM2.5', { text: info.sample.PM25_ugm3.toFixed(2), font: { weight: 'bold' } }],
]),
)
cursor2D
.setVisible(true)
.setPosition(...solveResults.map((solve) => solve.cursorPosition))
.setPosition({
pointMarker: { x: info.x, y: info.y },
pointMarkerScale: chart2D.coordsAxis,
resultTable: { x: info.x, y: info.y },
resultTableScale: chart2D.coordsAxis,
})
.setResultTable((rt) =>
rt.setContent([
[{ text: 'Route', font: { weight: 'bold' }, rowFillStyle: new SolidFill({ color: colors.bg }) }],
['Longitude', { text: info.x.toFixed(3), font: { weight: 'bold' } }],
['Latitude', { text: info.y.toFixed(3), font: { weight: 'bold' } }],
]),
)
} else {
cursor3D.setVisible(false)
cursor2D.setVisible(false)
}
} else {
const sample = { x: info.x, y: info.y, z: info.z }
const translation = chart3D.translateCoordinate(sample, chart3D.coordsAxis, chart3D.coordsRelative)
cursor3D
.setVisible(true)
.setPosition({
axisLocation: { x: sample.x, y: sample.y, z: sample.z },
resultTableScale: chart3D.coordsRelative,
resultTable: translation,
})
.setResultTable((rt) =>
rt.setContent([
[{ text: 'Concentration', font: { weight: 'bold' }, rowFillStyle: new SolidFill({ color: colors.bg }) }],
['PM2.5', { text: info.y.toFixed(2), font: { weight: 'bold' } }],
]),
)
cursor2D
.setVisible(true)
.setPosition({
pointMarker: { x: info.x, y: info.z },
pointMarkerScale: chart2D.coordsAxis,
resultTable: { x: info.x, y: info.z },
resultTableScale: chart2D.coordsAxis,
})
.setResultTable((rt) =>
rt.setContent([
[{ text: 'Location', font: { weight: 'bold' }, rowFillStyle: new SolidFill({ color: colors.bg }) }],
['Longitude', { text: info.x.toFixed(3), font: { weight: 'bold' } }],
['Latitude', { text: info.z.toFixed(3), font: { weight: 'bold' } }],
]),
)
}
}
series3D.addEventListener('pointermove', (_event, info) => {
displayCursorAt(info)
})
series3D.addEventListener('pointerleave', () => hideCursor())
series2D.addEventListener('pointermove', (_event, info) => {
displayCursorAt(info)
})
series2D.addEventListener('pointerleave', () => hideCursor())
const streamData = () => {
fetch(document.head.baseURI + 'examples/assets/1715/route_segment_interpolated.json')
.then((r) => r.json())
.then((data) => {
const threshold = 15
let sum = 0
let count = 0
let above = 0
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (!item.interpolated && typeof item.PM25_ugm3 === 'number') {
if (item.PM25_ugm3 > threshold) above++
sum += item.PM25_ugm3
count++
}
}
const avgPM25 = sum / count
const abovePM25 = (above / count) * 100
const pm25s = findMinMax(data, 'PM25_ugm3')
const longitudes = findMinMax(data, 'longitude')
const latitudes = findMinMax(data, 'latitude')
gaugeAvg.setValue(avgPM25)
gaugeMax.setValue(pm25s.max)
pie.addSlice('Above 15 µg/m³', abovePM25.toFixed(1))
pie.addSlice('Below 15 µg/m³', (100 - abovePM25).toFixed(1))
pie.setLabelFormatter((slice, relativeValue) => `${slice.getValue()} %`)
series3D.setStrokeStyle((stroke) =>
stroke.setFillStyle(
new PalettedFill({
lookUpProperty: 'y',
lut: new LUT({
interpolate: true,
steps: [
{ value: 0, color: colors.grey },
{ value: pm25s.min, color: colors.good },
{ value: 35, color: colors.neutral },
{ value: 75, color: colors.bad },
],
}),
}),
),
)
axisX3D.setDefaultInterval({ start: longitudes.min, end: longitudes.max })
axisY3D.setDefaultInterval({ start: 0, end: pm25s.max })
axisZ3D.setDefaultInterval({ start: latitudes.min, end: latitudes.max })
axisX2D.setDefaultInterval({ start: longitudes.min, end: longitudes.max })
axisY2D.setDefaultInterval({ start: latitudes.min, end: latitudes.max })
let index = 0
let aboveTh = 0
let time = new Date(data[0].timestamp).toLocaleString()
let distance = 0
const batchSize = 10
const animate = () => {
if (index >= data.length) return
for (let i = 0; i < batchSize && index < data.length; i++, index++) {
const p = data[index]
const interpolated = p.interpolated
const lookup = interpolated === true ? 100 : 0
const pmValue = interpolated === true ? 0 : p.PM25_ugm3
dataSet.appendSample({
x: p.longitude,
y: p.latitude,
PM25_ugm3: pmValue,
value: lookup,
})
if (!interpolated) {
time = new Date(p.timestamp).toLocaleString()
distance = (p.Distance_m / 1000).toFixed(1)
if (p.PM25_ugm3 > 15) aboveTh++
}
gaugeTime.setValue(aboveTh)
timeDiv.innerHTML = time
distanceDiv.innerHTML = `${distance} km`
}
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
})
.catch((error) => {
console.log(error)
})
}
streamData()
function findMinMax(data, key) {
const datas = data.map((node) => node[key])
const minValue = Math.min(...datas)
const maxValue = Math.max(...datas)
return {
min: Math.floor(minValue * 100) / 100,
max: Math.ceil(maxValue * 100) / 100,
}
}
Air Quality Route Visualization Dashboard - Editor This example is a multi-chart LightningChart JS dashboard visualizing PM2.5 concentration along a driving route in Dublin, Ireland. PM2.5 refers to particulate matter 2.5 µm or less in diameter. These microscopic pollutants can penetrate deep into the body and cause severe heart and lung diseases.
The 3D chart plots PM2.5 over longitude and latitude along the route, while the XY chart shows mobile sensor measurement locations on a map.
The first two Gauge charts display the average and peak PM2.5 values relative to WHO guideline limits (5 µg/m³ yearly, 15 µg/m³ daily). The Pie chart shows the proportion of time spent above the 15 µg/m³ threshold. The third Gauge chart shows the cumulative time above threshold.
The 3D and XY chart use a shared data set:
const dataSet = new DataSet ( {
schema: {
x: { pattern: null } ,
y: { pattern: null } ,
PM25_ugm3: { pattern: null } ,
time: { pattern: 'progressive' } ,
distance: { pattern: 'progressive' } ,
value: { pattern: null } ,
pointSize: { pattern: null }
}
} )
dataSet. setMaxSampleCount ( 500_000 )
series3D. setDataSet ( dataSet, { x: 'x' , y: 'PM25_ugm3' , z: 'y' , time: 'time' , distance: 'distance' } )
series2D. setDataSet ( dataSet, { x: 'x' , y: 'y' , size: 'pointSize' , lookupValue: 'value' } ) Data: Google Project Air View Map: OpenStreetMap WHO PM2.5 guideline reference: WHO Global Air Quality Guidelines (PDF)
The visualized data segment represents a 47-minute, 25 km route driven on 26 May 2021, with 2847 total samples. Pollutant values were sampled at 1-second intervals, with linear interpolation applied where necessary and all interpolated readings explicitly marked in the visualization.