Packages
noora
0.53.1
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/Chart.js
import * as echarts from "echarts";
import { parse, formatHex } from "culori";
import { formatHours } from "./formatters.js";
/**
* Formats elapsed time into a human readable string
* @param {number} milliseconds - The elapsed time in milliseconds
* @returns {string} Formatted time string
*/
function formatSeconds(seconds) {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${Math.round(seconds * 10) / 10}s`;
} else if (seconds == 60) {
return "1m";
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (remainingSeconds == 0) {
return `${minutes}m`;
} else {
return `${minutes}m ${remainingSeconds}s`;
}
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (minutes == 0) {
return `${hours}h`;
} else {
return `${hours}h ${minutes}m`;
}
} else {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return `${days}d ${hours}h`;
}
}
function formatMilliseconds(milliseconds) {
return formatSeconds(milliseconds / 1000);
}
function formatBytes(bytes) {
if (bytes >= 1_000_000_000) {
return `${(bytes / 1_000_000_000).toFixed(0)} GB`;
} else if (bytes >= 1_000_000) {
return `${(bytes / 1_000_000).toFixed(0)} MB`;
} else if (bytes >= 1_000) {
return `${(bytes / 1_000).toFixed(0)} KB`;
} else {
return `${bytes} MB`;
}
}
const formatters = {
toLocaleDate: (el) => (value, _) => {
const date = new Date(value);
return date.toLocaleDateString(locale(), {
day: "numeric",
month: "short",
});
},
formatBytes: (el) => (value, _) => {
return formatBytes(value);
},
formatMilliseconds: (el) => (value, _) => {
return formatMilliseconds(value);
},
formatSeconds: (el) => (value, _) => {
return formatSeconds(value);
},
formatHours: (el) => (value, _) => {
return formatHours(value);
},
};
const tooltipFormatters = {
formatBytes,
formatMilliseconds,
formatSeconds,
formatHours: (value) => formatHours(value, { includeMinutes: true }),
};
function locale() {
const region = (navigator.language || navigator.userLanguage).split("-")[1];
return `en-${region}`;
}
export default {
mounted() {
this.render();
this.colorSchemeListener = () => this.render();
window.addEventListener(
"changed-preferred-theme",
this.colorSchemeListener,
);
},
render() {
if (this.chart) this.chart.dispose();
const option = this.option();
const theme = getTheme(option);
echarts.registerTheme("noora", theme);
this.chart = echarts.init(
this.el.querySelector("[data-part='chart']"),
"noora",
{ renderer: "canvas" },
);
this.chart.setOption(option);
this.resizeListener = () => {
this.chart.resize();
};
window.addEventListener("resize", this.resizeListener);
window.addEventListener("phx:resize", this.resizeListener);
},
updated() {
// Re-render fully to update theme (including tooltip formatter)
this.render();
},
destroyed() {
this.chart.dispose();
window.removeEventListener(
"changed-preferred-theme",
this.colorSchemeListener,
);
window.removeEventListener("resize", this.resizeListener);
window.removeEventListener("phx:resize", this.resizeListener);
},
option() {
let option = {};
try {
option = JSON.parse(
this.el.querySelector("[data-part='data']").textContent,
);
if (
option.legend &&
option.legend.textStyle &&
option.legend.textStyle.color
) {
option.legend.textStyle.color = processColor(
option.legend.textStyle.color,
);
}
if (option.series && Array.isArray(option.series)) {
option.series = processSeriesColors(option.series);
// Calculate the size of the largest series which we use for later calculations.
let largestSeriesCount = 0;
option.series.forEach((series) => {
if (series.data && Array.isArray(series.data)) {
const itemCount = series.data.length;
largestSeriesCount = Math.max(largestSeriesCount, itemCount);
}
});
if (largestSeriesCount > 0) {
this.el.setAttribute("data-largest-series-count", largestSeriesCount);
}
}
const formatterPaths = ["xAxis.axisLabel", "yAxis.axisLabel"];
formatterPaths.forEach((path) => {
const parts = path.split(".");
const parent = parts.reduce((obj, part) => obj && obj[part], option);
if (
parent &&
parent.formatter &&
typeof parent.formatter === "string" &&
parent.formatter.startsWith("fn:")
) {
const functionName = parent.formatter.substring(3);
if (functionName in formatters) {
parent.formatter = formatters[functionName](this.el);
} else if (
window.nooraChartFormatters &&
functionName in window.nooraChartFormatters
) {
parent.formatter = window.nooraChartFormatters[functionName](
this.el,
);
}
}
});
} catch (err) {
console.error("Failed to parse ECharts options:", err);
}
if (option.yAxis.splitLine.lineStyle.color) {
option.yAxis.splitLine.lineStyle.color = processColor(
option.yAxis.splitLine.lineStyle.color,
);
}
if (option.yAxis.axisLabel.color) {
option.yAxis.axisLabel.color = processColor(option.yAxis.axisLabel.color);
}
if (option.xAxis.axisLabel.color) {
option.xAxis.axisLabel.color = processColor(option.xAxis.axisLabel.color);
}
return option;
},
};
// Private helper functions
// Theme
function getTheme(option) {
return {
color: colors(option),
tooltip: {
trigger: "item",
appendTo: "body",
// Reset all existing styling
backgroundColor: "transparent",
borderColor: "transparent",
padding: 0,
extraCssText: "box-shadow: none",
textStyle: {
fontFamily: "Inter",
},
formatter: tooltipFormatter({
valueFormat: option?.tooltip?.valueFormat,
dateFormat: option?.tooltip?.dateFormat,
}),
},
line: {
emphasis: {
lineStyle: {
width: "bolder",
},
},
},
};
}
function processColor(color) {
if (typeof color === "string" && color.startsWith("var:")) {
const variable = color.substring(4);
const value = getComputedStyle(document.documentElement)
.getPropertyValue(`--${variable}`)
.trim();
color = resolveLightDark(value);
}
// ECharts expects colors to be hex and shows unintended behavior such as broken hover states when using OKLCH, which we are generally
// using elsewhere.
return formatHex(parse(color));
}
function resolveLightDark(string) {
const regex = /light-dark\(\s*(.*?)\s*,\s*(.*?)\s*\)$/;
const match = string.match(regex);
if (!match) return string;
const currentTheme = localStorage.getItem("preferred-theme");
if (currentTheme == "light") {
return match[1];
} else if (currentTheme == "dark") {
return match[2];
} else {
return window.matchMedia("(prefers-color-scheme: light)").matches
? match[1]
: match[2];
}
}
function colors(option) {
if (!option.colors || !Array.isArray(option.colors)) return [];
return option.colors.map(processColor);
}
function processSeriesColors(series) {
if (!series || !Array.isArray(series)) return series;
return series.map((seriesItem) => {
// Process top-level color property
seriesItem.color = transformColorProperty(seriesItem.color);
// Process style objects with color properties
const styleProperties = ["itemStyle", "lineStyle", "areaStyle"];
styleProperties.forEach((styleProp) => {
if (seriesItem[styleProp] && seriesItem[styleProp].color) {
seriesItem[styleProp].color = processColor(seriesItem[styleProp].color);
}
});
if (seriesItem.itemStyle && seriesItem.itemStyle.borderColor) {
seriesItem.itemStyle.borderColor = processColor(
seriesItem.itemStyle.borderColor,
);
}
// Process colors in data items
if (seriesItem.data && Array.isArray(seriesItem.data)) {
seriesItem.data.forEach((dataItem) => {
processItemColor(dataItem);
});
}
return seriesItem;
});
}
function processItemColor(dataItem) {
if (dataItem && typeof dataItem === "object" && dataItem.itemStyle) {
if (dataItem.itemStyle.color) {
dataItem.itemStyle.color = processColor(dataItem.itemStyle.color);
}
if (dataItem.itemStyle.borderColor) {
dataItem.itemStyle.borderColor = processColor(
dataItem.itemStyle.borderColor,
);
}
if (dataItem.children) {
dataItem.children.forEach((child) => {
processItemColor(child);
});
}
}
}
function transformColorProperty(colorProp) {
if (!colorProp) return colorProp;
if (Array.isArray(colorProp)) {
return colorProp.map((color) => processColor(color));
}
return processColor(colorProp);
}
// Tooltip
function tooltipFormatter(options = {}) {
return (params) => {
const content = Array.isArray(params)
? params.map((param) => tooltipSeries(param, options)).join("")
: tooltipSeries(params, options);
let title = params[0].name;
if (!Number.isNaN(Date.parse(title))) {
const date = new Date(title);
if (options.dateFormat == "minute") {
title = date.toLocaleDateString(locale(), {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "numeric",
});
} else if (options.dateFormat == "hour") {
const dateStr = date.toLocaleDateString(locale(), {
day: "numeric",
month: "short",
year: "numeric",
});
const hour = String(date.getHours()).padStart(2, "0");
title = `${dateStr}, ${hour}:00`;
} else {
title = date.toLocaleDateString(locale(), {
day: "numeric",
month: "short",
year: "numeric",
});
}
}
return `<div class="noora-chart-tooltip">
<span data-part="title">${title}</span>
<div class="noora-line-divider">
<div data-part="line"></div>
</div>
${content}
</div>`;
};
}
function tooltipSeries({ color, seriesName, value }, options = {}) {
if (!seriesName && Array.isArray(value)) {
const date = new Date(value[0]);
if (date instanceof Date && !isNaN(date)) {
seriesName = new Intl.DateTimeFormat(navigator.language).format(date);
value = value[1];
}
}
if (Array.isArray(value) && value.length > 0) {
value = value[value.length - 1];
}
if (value !== null && typeof value === "object" && "value" in value) {
value = value.value;
}
let formattedValue;
if (options.valueFormat && typeof options.valueFormat === "string") {
if (options.valueFormat.startsWith("fn:")) {
const functionName = options.valueFormat.substring(3);
if (functionName in tooltipFormatters) {
formattedValue = tooltipFormatters[functionName](value);
}
} else {
formattedValue = options.valueFormat.replace("{value}", value);
}
} else {
formattedValue = value;
}
return `
<div data-part="series-item">
<span data-part="dot" style="--color: ${Array.isArray(color) ? color[0] : color}"></span>
<span data-part="label">${seriesName}</span>
<span data-part="value">${formattedValue}</span>
</div>
`;
}