Close Editor Run Reset Auto Update CJS const lcjs = require('@lightningchart/lcjs')
const { lightningChart, Themes, MapTypes, isHitRectangle, AxisTickStrategies, SolidLine, SolidFill, transparentFill, emptyLine, FontSettings, ColorRGBA } = lcjs
// Data and configuration
const zoneCoords = {
'FI': { geoCode: 'FI', coords: { x: 30.0, y: 66.0 } },
'NO1': { geoCode: 'NO_1', coords: { x: -5.0, y: 64.0 } },
'NO2': { geoCode: 'NO_2', coords: { x: -5.0, y: 59.0 } },
'NO3': { geoCode: 'NO_3', coords: { x: -1.0, y: 66.5 } },
'NO4': { geoCode: 'NO_4', coords: { x: 5.0, y: 70.5 } },
'NO5': { geoCode: 'NO_5', coords: { x: -5.0, y: 61.5 } },
'SE1': { geoCode: 'SE_1', coords: { x: 17.0, y: 68.0 } },
'SE2': { geoCode: 'SE_2', coords: { x: 14.5, y: 64.0 } },
'SE3': { geoCode: 'SE_3', coords: { x: 14.5, y: 60.0 } },
'SE4': { geoCode: 'SE_4', coords: { x: 14.5, y: 58.0 } },
'DK1': { geoCode: 'DK_1', coords: { x: -1.0, y: 56.5 } },
'DK2': { geoCode: 'DK_2', coords: { x: 14.5, y: 56.1 } },
'EE': { geoCode: 'EE', coords: { x: 29.0, y: 60.2 } },
'LV': { geoCode: 'LV', coords: { x: 29.0, y: 58.2 } },
'LT': { geoCode: 'LT', coords: { x: 29.0, y: 56.1 } }
}
const zonePolygons = {}
for (let zone in zoneCoords) {
zonePolygons[zone] = []
}
const globalData = {}
const priceData = {}
let allGenTypes = []
let activeGenTypes = []
let globalMaxPrice = 0
let selectedZone = ''
const divs = []
const smallContainers = []
const buttons = []
// HTML structure
const exampleContainer = document.getElementById('chart') || document.body
if (exampleContainer === document.body) {
exampleContainer.style.width = '100vw'
exampleContainer.style.height = '100vh'
exampleContainer.style.margin = '0px'
}
const mapContainer = document.createElement('div')
const chartContainer = document.createElement('div')
const sideBar = document.createElement('div')
const gridContainer = document.createElement('div')
const selectorContainer = document.createElement('div')
exampleContainer.append(mapContainer)
exampleContainer.append(sideBar)
exampleContainer.append(chartContainer)
exampleContainer.append(selectorContainer)
const headerDiv = document.createElement('div')
const newContent = document.createTextNode("Filter charts: ")
headerDiv.appendChild(newContent)
selectorContainer.appendChild(headerDiv)
const lc = lightningChart()
// Create Map Chart
const mapChart = lc.Map({
container: mapContainer,
type: MapTypes.Nordics,
legend: { visible: false },
// theme: Themes.darkGold,
})
// Dashboard colors
const theme = mapChart.getTheme()
const palette = {
color1: theme.mapChartFillStyle.stops[0].color,
transparent: '#00000000',
lightest: '#fafafa',
light: '#e6e4e4',
mid: '#777',
dark: '#222',
darkest: '#0a0a0a',
lightBG: ColorRGBA(250, 250, 250),
darkBG: ColorRGBA(10, 10, 10),
red: ColorRGBA(250, 0, 0)
}
const colorMap = {
'Wind Onshore': ColorRGBA(0, 188, 212),
'Wind Offshore': ColorRGBA(33, 150, 243),
'Solar': ColorRGBA(255, 193, 7),
'Hydro Run-of-river and pondage': ColorRGBA(5, 193, 145),
'Hydro Water Reservoir': ColorRGBA(0, 150, 136),
'Hydro Pumped Storage': ColorRGBA(0, 105, 92),
'Nuclear': ColorRGBA(255, 87, 34),
'Biomass': ColorRGBA(76, 175, 80),
'Other renewable': ColorRGBA(139, 195, 74),
'Fossil Fuels': ColorRGBA(63, 81, 181),
'Waste': ColorRGBA(156, 39, 176),
'Other': ColorRGBA(120, 144, 156),
'Energy storage': ColorRGBA(233, 30, 99)
}
const fontFamily = theme.mapChartTitleFont.family
// CSS styling
Object.assign(exampleContainer.style, {
display: 'grid',
gridTemplateColumns: '80% 20%',
gridTemplateRows: '55% 35% auto',
overflow: 'hidden',
background: theme.isDark ? palette.darkest : palette.lightest
})
Object.assign(mapContainer.style, {
gridColumn: '1 / 2',
gridRow: '1 / 2',
position: 'relative',
width: '100%',
height: '100%',
borderBottom: `2px solid ${theme.isDark ? palette.dark : palette.light}`
})
Object.assign(chartContainer.style, {
gridColumn: '1 / 2',
gridRow: '2 / 3',
position: 'relative',
width: '100%',
height: '100%',
borderTop: `2px solid ${theme.isDark ? palette.dark : palette.light}`
})
Object.assign(sideBar.style, {
gridColumn: '2 / 3',
gridRow: '1 / 3',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
width: '100%',
height: '100%',
background: theme.isDark ? palette.darkest : palette.lightest,
borderLeft: `2px solid ${theme.isDark ? palette.dark : palette.light}`
})
Object.assign(gridContainer.style, {
flex: '1',
overflowY: 'auto',
display: 'grid',
alignContent: 'start',
gridAutoRows: 'min-content',
gridTemplateRows: 'unset',
gridTemplateColumns: '0.8fr 1fr 1fr 1.1fr 0.8fr',
width: '95%',
height: 'auto',
margin: '0 auto',
padding: '6px',
color: theme.isDark ? palette.light : palette.dark
})
Object.assign(selectorContainer.style, {
gridColumn: '1 / 3',
gridRow: '3 / 4',
display: 'flex',
flexWrap: 'wrap',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
gap: '4px',
padding: '6px',
marginTop: '2px',
boxSizing: 'border-box',
background: theme.isDark ? palette.darkest : palette.lightest,
borderTop: `2px solid ${theme.isDark ? palette.dark : palette.light}`,
overflow: 'auto'
})
Object.assign(headerDiv.style, {
marginRight: '8px',
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
color: theme.isDark ? palette.light : palette.dark,
fontFamily: fontFamily,
fontSize: '12px',
fontWeight: 'bold'
})
// Create HTML elements for small charts
for(let zone in zoneCoords) {
const overlayDiv = document.createElement('div')
overlayDiv.id = zone + 'OverlayDiv'
divs.push({zone: zone, overlayDiv: overlayDiv})
}
// Map Chart configuration
mapChart
.setTitle('Nord Pool Day-Ahead Market View - 12 Jan 2026')
.setTitleMargin(0)
.setPadding(0)
.setEffect(false)
.setHighlightOnHover(false)
.setCursorMode(undefined)
.setPointerEvents(false)
.setFillStyle(new SolidFill({ color: palette.color1.setA(20) }))
.setStrokeStyle(emptyLine)
.setOutlierRegionStrokeStyle(emptyLine)
.setSeparateRegionVisible(false)
// Create overlay XY Chart
const chartXY = lc
.ChartXY({
container: mapContainer,
legend: { visible: false },
// theme: Themes.darkGold,
})
.setTitle('')
.setTitleMargin(0)
.setPadding(0)
.setCursorMode(undefined)
.setUserInteractions(undefined)
.setBackgroundFillStyle(transparentFill)
.setSeriesBackgroundFillStyle(transparentFill)
.setSeriesBackgroundStrokeStyle(emptyLine)
chartXY.engine.setBackgroundFillStyle(transparentFill)
chartXY.getDefaultAxes().forEach((axis) => axis.setTickStrategy(AxisTickStrategies.Empty).setStrokeStyle(emptyLine))
// Add bidding zones as polygons to chartXY
const polygonSeries = chartXY.addPolygonSeries()
.setCursorEnabled(false)
const setPolygonStyle = (figure, isHighlighted) => {
if (isHighlighted) {
figure.setFillStyle(new SolidFill({ color: palette.color1.setA(200) }))
figure.setStrokeStyle(new SolidLine({ thickness: 2, fillStyle: new SolidFill({ color: palette.color1 }) }))
} else {
figure.setFillStyle(new SolidFill({ color: palette.color1.setA(80) }))
figure.setStrokeStyle(new SolidLine({ thickness: 1, fillStyle: new SolidFill({ color: palette.color1 }) }))
}
}
// Data loading functions
const loadZoneData = () => {
return fetch(document.head.baseURI + 'examples/assets/1714/cleaned_energy_data.json')
.then((r) => r.json())
.then((data) => {
Object.assign(globalData, data)
return data
})
.catch(err => console.error("Error loading ZoneData:", err))
}
const loadPriceData = () => {
return fetch(document.head.baseURI + 'examples/assets/1714/energy_prices.json')
.then((r) => r.json())
.then((data) => {
Object.assign(priceData, data)
return data
})
.catch(err => console.error("Error loading PriceData:", err))
}
const loadBiddingZone = (zone, info) => {
zonePolygons[zone] = []
const offset = { x: -0.4, y: -0.7 }
const scale = { x: 1.01, y: 1.01 }
const url = 'https://raw.githubusercontent.com/EnergieID/entsoe-py/master/entsoe/geo/geojson/' + info.geoCode + '.geojson'
return fetch(url)
.then(r => r.json())
.then((geojson) => {
geojson.features.forEach((feature) => {
const { type, coordinates } = feature.geometry
const processRing = (ring) => {
const points = ring.map((tuple) => ({x: (tuple[0] * scale.x) + offset.x, y: (tuple[1] * scale.y) + offset.y}))
const polygonFigure = polygonSeries.add(points)
setPolygonStyle(polygonFigure, false)
zonePolygons[zone].push(polygonFigure)
}
if (type === 'Polygon') coordinates.forEach(processRing)
else if (type === 'MultiPolygon') coordinates.forEach((polygon) => { polygon.forEach(processRing) })
})
})
.catch(err => console.error("Error loading GeoJSON:", err))
}
// Create XY Chart that shows currently selected bidding zone
const bigChart = lc.ChartXY({
container: chartContainer,
legend: { visible: false },
// theme: Themes.darkGold,
})
.setTitleMargin({ top: 0, bottom: 10 })
.setCursorMode('show-nearest')
bigChart.getDefaultAxisX().setTitle('Time').setTickStrategy(AxisTickStrategies.Empty)
bigChart.getDefaultAxisY().setTitle('Generation')
bigChart.addAxisY({ opposite: true }).setTitle('Price')
// Create small XY Charts for each bidding zone
const createSmallChart = (div, zone) => {
mapContainer.append(div)
const smallChart = lc.ChartXY({
container: div,
legend: { visible: false },
// theme: Themes.darkGold,
})
.setTitle(zone)
.setTitleEffect(false)
.setCursorMode(undefined)
.setPadding(0)
.setTitleFont(new FontSettings({ size: 11 }))
.setTitleMargin(0)
smallChart.getDefaultAxisX().setTickStrategy(AxisTickStrategies.Empty).setMarginAfterTicks(4)
smallChart.getDefaultAxisY().setTickStrategy(AxisTickStrategies.Empty).setMarginAfterTicks(4)
smallChart.addAxisY({ opposite: true }).setTickStrategy(AxisTickStrategies.Empty).setMarginAfterTicks(4)
div.style.position = 'absolute'
div.style.zIndex = '1'
div.style.width = '10%'
div.style.height = '10%'
div.style.cursor = 'pointer'
div.addEventListener('click', () => { handleClick(zone) })
return smallChart
}
// Create buttons for filtering generation types
const addButtons = () => {
const ids = [...allGenTypes, 'All']
ids.forEach((type) => {
let isRemoved = false
const color = type === 'All' ? (theme.isDark ? palette.lightest : palette.dark) : colorMap[type].toRGBAString()
const btn = document.createElement('button')
btn.innerHTML = type === 'All' ? 'Hide All' : type
btn.id = type
Object.assign(btn.style, {
height: '24px',
padding: '0 6px',
fontSize: '11px',
fontFamily: fontFamily,
fontWeight: 'bold',
flex: '0 1 auto',
cursor: 'pointer',
color: color,
background: theme.isDark ? palette.dark : palette.lightest,
border: `2px solid ${color}`,
borderRadius: '6px',
whiteSpace: 'nowrap'
})
selectorContainer.append(btn)
buttons.push({ id: btn.id, button: btn, color: color, isRemoved: isRemoved })
})
}
// Add generation and price summary data to the sidebar for each bidding zone
const addDataToSideBar = () => {
const headers = ['Zone', 'Vol (GWh)', '% Ren', 'Price', 'Level']
const renewables = [
'Biomass',
'Hydro Pumped Storage',
'Hydro Run-of-river and pondage',
'Hydro Water Reservoir',
'Other renewable',
'Solar',
'Wind Offshore',
'Wind Onshore'
]
const zoneStats = []
const zones = Object.keys(globalData)
zones.forEach(zone => {
let totalRaw = 0
let renRaw = 0
globalData[zone].categories.forEach(cat => {
const catSum = cat.values.reduce((a, b) => a + b, 0)
totalRaw += catSum
if (renewables.includes(cat.subCategory)) {
renRaw += catSum
}
})
const totalVolumeGWh = totalRaw / 1000 // GWh
const renPct = totalRaw > 0 ? (renRaw / totalRaw) * 100 : 0
const priceList = priceData[zone]
const avgPrice = priceList.length ? (priceList.reduce((a, b) => a + b, 0) / priceList.length) : 0
let statusText = ''
if (avgPrice < 50) statusText = '🟢'
else if (avgPrice <= 100) statusText = '🟡'
else statusText = '🔴'
zoneStats.push({
zone: zone,
volume: totalVolumeGWh,
renPct: renPct,
price: avgPrice,
statusText: statusText
})
})
zoneStats.sort((a, b) => b.volume - a.volume)
headers.forEach(text => {
const headerDiv = document.createElement('div')
headerDiv.innerText = text
Object.assign(headerDiv.style, {
fontWeight: 'bold',
fontSize: '12px',
fontFamily: fontFamily,
cursor: 'default',
borderBottom: `1px solid ${theme.isDark ? palette.mid : palette.light}`,
paddingBottom: '2px',
color: theme.isDark ? palette.light : palette.dark
})
gridContainer.appendChild(headerDiv)
})
zoneStats.forEach(stat => {
const values = [
stat.zone,
stat.volume.toFixed(0),
stat.renPct.toFixed(0) + ' %',
stat.price.toFixed(0) + ' €',
stat.statusText
]
values.forEach((val) => {
const dataDiv = document.createElement('div')
dataDiv.innerText = val
dataDiv.class = stat.zone
Object.assign(dataDiv.style, {
fontSize: '12px',
fontFamily: fontFamily,
cursor: 'pointer',
padding: '4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
color: theme.isDark ? palette.light : palette.dark
})
// Add click events to rows to trigger zone selection
dataDiv.addEventListener('click', () => { handleClick(stat.zone) })
dataDiv.classList.add('sidebar-cell', stat.zone)
gridContainer.appendChild(dataDiv)
})
})
// Add info box at the bottom of the sidebar
const infoBox = document.createElement('div')
Object.assign(infoBox.style, {
flex: '0 0 auto',
padding: '6px',
margin: '0px',
background: theme.isDark ? palette.darkest : palette.lightest,
fontSize: '12px',
fontFamily: fontFamily,
color: theme.isDark ? palette.light : palette.dark,
borderTop: `2px solid ${theme.isDark ? palette.dark : palette.light}`
})
const barColor = 'linear-gradient(to bottom, #1046e6, #05ce42, #ff9e43)'
const lineColor = '#da2222'
const textColor = theme.isDark ? palette.lightest : palette.darkest
const subTextColor = theme.isDark ? '#ccc' : palette.dark
infoBox.innerHTML = `
<div style="margin-bottom: 8px;">
<strong style="color: ${textColor};">% Ren = </strong>
<span style="color: ${subTextColor};">Share of renewables (wind, solar, hydro, biomass)</span>
</div>
<div style="margin-bottom: 8px;">
<strong style="color: ${textColor};">Price Level</strong>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 4px; text-align: center; font-size: 10px;">
<div style="background: rgba(81, 207, 102, 0.2); color: ${theme.isDark ? '#51cf66' : '#2b8a3e'}; padding: 2px 0; border-radius: 4px;">
🟢 Low<br>&lt;50€
</div>
<div style="background: rgba(252, 196, 25, 0.2); color: ${theme.isDark ? '#fcc419' : '#f08c00'}; padding: 2px 0; border-radius: 4px;">
🟡 Med<br>50-100€
</div>
<div style="background: rgba(255, 107, 107, 0.2); color: ${theme.isDark ? '#ff6b6b' : '#c92a2a'}; padding: 2px 0; border-radius: 4px;">
🔴 High<br>&gt;100€
</div>
</div>
</div>
<div style="margin-bottom: 8px;">
<strong style="color: ${textColor};">Bidding Zone View</strong>
<div style="display: flex; align-items: center; margin-top: 4px; margin-bottom: 4px;">
<span style="display: inline-block; width: 12px; height: 12px; background: ${barColor}; border-radius: 2px; margin-top: 2px; margin-right: 8px;"></span>
<span style="color: ${subTextColor};">Scheduled Generation (MW)</span>
</div>
<div style="display: flex; align-items: center;">
<span style="display: inline-block; width: 12px; height: 3px; background: ${lineColor}; margin-right: 8px; position: relative; margin-top: 2px;"></span>
<span style="color: ${subTextColor};">Day-ahead Price (€/MWh)</span>
</div>
</div>
`
sideBar.appendChild(gridContainer)
sideBar.appendChild(infoBox)
}
// Render generation and price data for a given zone on a given chart
const renderZoneData = (chart, zone, activeTypes) => {
if (!globalData[zone] || !priceData[zone]) return
chart.getSeries().forEach(s => s.clear())
// If activeTypes is provided, filter to those types, otherwise show all types
const visibleTypes = activeTypes || globalData[zone].categories.map(c => c.subCategory)
const timestamps = globalData[zone].timestamps
const dataMap = {}
visibleTypes.forEach(type => {
const category = globalData[zone].categories.find(c => c.subCategory === type)
if (category) dataMap[type] = category.values
})
let zoneMaxMW = 0
for (let i = 0; i < timestamps.length; i++) {
let hourlyTotal = 0
globalData[zone].categories.forEach(cat => { hourlyTotal += cat.values[i] })
if (hourlyTotal > zoneMaxMW) zoneMaxMW = hourlyTotal
}
const seriesMap = {}
// Add rectangle series for each generation type and stack them on top of each other
visibleTypes.forEach(type => {
const rectSeries = chart.addRectangleSeries({})
rectSeries.setName(type)
rectSeries.setDefaultStyle((figure) => figure
.setFillStyle(new SolidFill({ color: colorMap[type].setA(200) }))
.setStrokeStyle(new SolidLine({ thickness: 1, fillStyle: new SolidFill({ color: colorMap[type] }) }))
)
if (chart !== bigChart) rectSeries.setCursorEnabled(false).setHighlightOnHover(false)
seriesMap[type] = rectSeries
})
for (let i = 0; i < timestamps.length; i++) {
let currentStackY = 0
visibleTypes.forEach(type => {
const val = dataMap[type] ? dataMap[type][i] : 0
if (val > 0) {
seriesMap[type].add({
x: i,
y: currentStackY,
width: 0.95,
height: val
})
currentStackY += val
}
})
}
const axes = chart.getAxes()
axes[1].setInterval({ start: 0, end: zoneMaxMW * 1.05 })
if (globalMaxPrice > 0) {
axes[2].setInterval({ start: 0, end: globalMaxPrice })
}
// Add line series for price data
const priceSeries = chart.addLineSeries({
xAxis: chart.getDefaultAxisX(),
yAxis: axes[2],
schema: {
x: { pattern: 'progressive' },
y: { pattern: null },
}
})
priceSeries.setName('Price')
priceSeries.setStrokeStyle(new SolidLine({
thickness: chart === bigChart ? 4 : 2,
fillStyle: new SolidFill({ color: palette.red })
}))
if (chart !== bigChart) priceSeries.setCursorEnabled(false)
const pricePoints = priceData[zone].map((price, index) => ({ x: index, y: price }))
pricePoints.push({ x: 24, y: priceData[zone][23] })
priceSeries.appendJSON(pricePoints)
}
// Highlight selected zone in sidebar
const highlightZonefromList = (zone) => {
const allCells = document.querySelectorAll('.sidebar-cell')
allCells.forEach((cell) => {
cell.style.fontWeight = 'normal'
cell.style.background = palette.transparent
})
const targetCells = document.querySelectorAll('.' + zone)
targetCells.forEach((cell) => {
cell.style.fontWeight = 'bold'
cell.style.background = theme.isDark ? palette.dark : palette.light
})
}
// Handle clicking on a bidding zone
const handleClick = (zone) => {
if (zone === selectedZone) return
selectedZone = zone
// Highlight selected zone on small charts and map
smallContainers.forEach(({ smallChart }) => {
if (smallChart.getTitle() === selectedZone) {
smallChart.setBackgroundStrokeStyle(new SolidLine({ thickness: 4, fillStyle: new SolidFill({ color: palette.color1 }) }))
} else {
smallChart.setBackgroundStrokeStyle(mapChart.getOutlierRegionStrokeStyle())
}
smallChart.engine.layout()
})
Object.keys(zonePolygons).forEach((zoneKey) => {
const figures = zonePolygons[zoneKey]
const shouldHighlight = (zoneKey === selectedZone)
figures.forEach((figure) => {
setPolygonStyle(figure, shouldHighlight)
})
})
bigChart.setTitle('Bidding Zone: ' + selectedZone)
// Render data for the selected zone in the big chart
renderZoneData(bigChart, selectedZone, activeGenTypes)
// Highlight selected zone in sidebar
highlightZonefromList(selectedZone)
}
// Handle clicking on the generation type buttons on the bottom
const toggleButtons = (btn) => {
const btnId = btn.id
const btnRemoved = !btn.isRemoved
let isAll = false
// If "All" button is clicked, toggle all buttons, otherwise just toggle the clicked button
buttons.forEach((button) => {
if ( btnId === 'All') {
button.isRemoved = btnRemoved
isAll = true
} else {
if (button.id === btnId) {
button.isRemoved = btnRemoved
}
}
})
// Update button colors based on their state
const updateButtonColors = (btn, color, isRemoved) => {
if (isRemoved) {
Object.assign(btn.style, {
color: btn.id === 'All' ? color : palette.mid,
background: theme.isDark ? palette.dark : palette.light,
border: `2px solid ${btn.id === 'All' ? palette.mid : (theme.isDark ? palette.dark : palette.light)}`
})
} else {
Object.assign(btn.style, {
color: color,
background: theme.isDark ? palette.dark : palette.lightest,
border: `2px solid ${color}`
})
}
}
buttons.forEach(({ id, button, color, isRemoved }) => {
if (btnId === 'All' || btnId === id) {
updateButtonColors(button, color, isRemoved)
}
if (id === 'All' && btnId === 'All') {
button.innerHTML = isRemoved ? 'Show All' : 'Hide All'
}
})
if (btn.id == 'All' && btn.isRemoved) {
activeGenTypes = []
} else {
activeGenTypes = []
buttons.forEach((btn) => {
if (!btn.isRemoved && btn.id != 'All') activeGenTypes.push(btn.id)
})
}
// Re-render all charts with the updated activeGenTypes
smallContainers.forEach(({ zone, smallChart }) => {
renderZoneData(smallChart, zone, activeGenTypes)
})
if (selectedZone) {
renderZoneData(bigChart, selectedZone, activeGenTypes)
}
}
// Update positions of small charts on the map when the view changes (e.g. zoom, pan)
const updateChartPositions = () => {
requestAnimationFrame(() => {
const canvas = chartXY.engine.container.getBoundingClientRect()
smallContainers.forEach(({ zone, overlayDiv }) => {
const coords = zoneCoords[zone].coords
if (coords) {
const locationWebpage = chartXY.translateCoordinate(coords, chartXY.coordsAxis, chartXY.coordsClient)
overlayDiv.style.left = `${locationWebpage.clientX - canvas.left}px`
overlayDiv.style.top = `${locationWebpage.clientY - canvas.top}px`
}
})
})
}
// Listen to view changes on the map chart to update small chart positions
mapChart.addEventListener('viewchange', (event) => {
const { latitudeRange, longitudeRange, margin } = event
chartXY.setPadding({
left: margin.left,
right: margin.right,
top: margin.top,
bottom: margin.bottom,
})
chartXY.getDefaultAxisX().setInterval({ start: longitudeRange.start, end: longitudeRange.end })
chartXY.getDefaultAxisY().setInterval({ start: latitudeRange.start, end: latitudeRange.end })
updateChartPositions()
})
const loadingPromises = []
for(let zone in zoneCoords) {
loadingPromises.push(loadBiddingZone(zone, zoneCoords[zone]))
}
loadingPromises.push(loadZoneData())
loadingPromises.push(loadPriceData())
// Load all data and initialize charts and UI components once data is loaded
Promise.all(loadingPromises).then(() => {
allGenTypes = globalData.DK1.categories.map(c => c.subCategory)
activeGenTypes = [...allGenTypes]
divs.forEach(({ zone, overlayDiv }) => {
const smallChart = createSmallChart(overlayDiv, zone)
smallContainers.push({ zone, overlayDiv, smallChart })
})
addButtons()
buttons.forEach((button) => {
button.button.onclick = () => { toggleButtons(button) }
})
addDataToSideBar()
// Determine global max price across all zones to set consistent Y axis range for price in all charts
Object.values(priceData).forEach(prices => {
const zoneMax = Math.max(...prices)
if (zoneMax > globalMaxPrice) globalMaxPrice = zoneMax
})
globalMaxPrice = Math.ceil(globalMaxPrice)
smallContainers.forEach(({ zone, smallChart }) => {
renderZoneData(smallChart, zone, allGenTypes)
})
bigChart.setCursorFormatting((_, hit, hits) => {
const generation = hit.y2 - hit.y1
if (!isHitRectangle(hit)) {
return [
[{ component: hit.series, rowFillStyle: mapChart.getTheme().cursorResultTableHeaderBackgroundFillStyle }],
['€/MWh', hit.y.toFixed(0)]
]
}
return [
[{ component: hit.series, rowFillStyle: mapChart.getTheme().cursorResultTableHeaderBackgroundFillStyle }],
['Time', '', hit.x1.toFixed(0) + ':00'],
['MW', '', generation.toFixed(2)]
]
})
.setCursor((cursor) => cursor
.setTickMarkerXVisible(false)
.setGridStrokeXStyle(emptyLine)
.setTickMarkerYVisible(false)
.setGridStrokeYStyle(emptyLine)
.setPointMarkerVisible(false)
)
// Format X axis ticks to show hours and adapt to screen size
const formatAxisTicks = (chartWidth) => {
const ticks = bigChart.getDefaultAxisX().getCustomTicks()
if (ticks) {
ticks.forEach(tick => tick.dispose())
}
for (let i = 0; i < globalData.DK1.timestamps.length; i++) {
let hourString = globalData.DK1.timestamps[i]
if (chartWidth < 990) {
let splitStr = hourString.split(':')
hourString = splitStr[0]
}
const tick = bigChart.getDefaultAxisX().addCustomTick()
tick.setValue(i)
tick.setTextFormatter((position, customTick) => hourString)
tick.setGridStrokeLength(0)
}
}
formatAxisTicks(bigChart.getSizePixels().x)
bigChart.engine.addEventListener('resize', (event) => {
formatAxisTicks(event.width)
})
// Initial layout and position update after all charts are set up
chartXY.engine.layout()
updateChartPositions()
handleClick('NO1')
}) JavaScript Drill-Down Nord Pool Map Dashboard - Editor This dashboard visualizes day-ahead market data from the Nord Pool energy exchange. It provides a geographic overview of the next day's planned power generation across the Nordic-Baltic region.
You can click any bidding zone (small charts) or sidebar row to drill down into its detailed energy mix below, or use the buttons to filter specific energy sources globally. A red Day-ahead Price line overlays all charts, making it easy to spot market correlations, such as price drops during high wind availability.
The application uses a Map chart for the geographical background, while the bidding zone boundaries are rendered using a PolygonSeries within a transparent ChartXY overlay. This allows for custom highlighting and styling of GeoJSON shapes that differ from the standard map regions.
The Map chart's geospatial coordinate system is synchronized with the XY axes:
mapChart. addEventListener ( 'viewchange' , ( event ) => {
const { latitudeRange, longitudeRange, margin } = event
chartXY. getDefaultAxisX ( ) . setInterval ( { start: longitudeRange. start, end: longitudeRange. end } )
chartXY. getDefaultAxisY ( ) . setInterval ( { start: latitudeRange. start, end: latitudeRange. end } )
updateChartPositions ( )
} ) Data sources used in this example:ENTSO-E Transparency Platform Nord Pool Day-ahead Prices GitHub - Python client for the ENTSO-E API