@ndbx/runtime
Version:
The `@ndbx/runtime` package provides a runtime environment to embed NodeBox visualizations directly into React applications. NodeBox is a powerful tool for creating interactive and generative visualizations, and this runtime allows you to integrate those
1,513 lines (1,378 loc) • 121 kB
JavaScript
/*
* Utility functions and variables for NodeBox Plot.
*/
import { parse as parseVega, parseExpression, expressionFunction } from "https://esm.sh/vega@5";
import {
min,
max,
ascending,
descending,
rollup,
sum,
bin,
range as d3range,
ticks,
} from "https://esm.sh/d3-array@3.2.4";
import {
scaleLinear,
scaleTime,
scaleUtc,
scalePow,
scaleSqrt,
scaleLog,
scaleSymlog,
scaleOrdinal,
scaleBand,
scalePoint,
scaleDiverging,
scaleQuantile,
scaleQuantize,
scaleThreshold,
} from "https://esm.sh/d3-scale@4.0.2";
import {
geoAlbers,
geoAlbersUsa,
geoAzimuthalEqualArea,
geoAzimuthalEquidistant,
geoConicConformal,
geoConicEqualArea,
geoConicEquidistant,
geoEquirectangular,
geoGnomonic,
geoMercator,
geoOrthographic,
geoStereographic,
geoTransverseMercator,
geoNaturalEarth1,
} from "https://esm.sh/d3-geo@3.1.1";
import proj4 from "https://esm.sh/proj4@2.15.0";
/*
-------- GENERAL FUNCTIONS --------
*/
export function debugPrint(value) {
return console.log("DEBUG PRINT", value);
}
export function validateVegaSpec(spec) {
try {
parseVega(spec); // Parses the spec, throws an error if invalid
//console.log('Vega specification is valid.');
return true;
} catch (err) {
throw new Error("Invalid Vega specification.", err.message);
}
}
// Function to convert nodeBox color format to CSS color string
export function nbColorToCSS(color) {
// Check if the input is a string (CSS color)
if (color === undefined) {
return color;
} else if (typeof color === "string") {
return color; // Return CSS color as is
} else {
// If the input is an object, process the RGBA values
const { r, g, b, a } = color;
const red = Math.round(r * 255);
const green = Math.round(g * 255);
const blue = Math.round(b * 255);
return `rgba(${red}, ${green}, ${blue}, ${a})`;
}
}
// Helper to check arrays for NaN recursively
function containsNaN(arr) {
if (arr !== undefined && Array.isArray(arr)) {
return arr.some((item) => {
if (Array.isArray(item)) {
// Recursively check nested arrays
return containsNaN(item);
}
// Check if the item is NaN
return typeof item === "number" && isNaN(item);
});
} else {
return false;
}
}
/*
-------- DEFAULTS VARIABLES --------
*/
export const emptyPlot = {
$schema: "https://vega.github.io/schema/vega/v5.json",
description: "A default empty plot specification.",
width: 400,
height: 400,
padding: 10,
autosize: "pad",
config: {
background: "#fff",
},
signals: [],
data: [
{
name: "table",
values: [],
},
],
scales: [
{
name: "xScale",
type: "linear",
range: "width",
domain: [0, 1],
},
{
name: "yScale",
type: "linear",
range: "height",
domain: [0, 1],
},
],
projections: [],
axes: [
{
orient: "bottom",
scale: "xScale",
offset: 5,
},
{
orient: "left",
scale: "yScale",
offset: 5,
},
],
legends: [],
marks: [
{
type: "symbol",
from: { data: "table" },
encode: {
enter: {
size: { value: 50 },
},
update: {
x: { scale: "xScale", field: "__x" },
y: { scale: "yScale", field: "__y" },
},
},
},
],
};
export const emptyMap = {
$schema: "https://vega.github.io/schema/vega/v5.json",
description: "A default empty geodata plot specification.",
width: 400,
height: 400,
padding: 10,
autosize: "none",
config: {
background: "#fff",
},
projections: [],
signals: [],
data: [],
scales: [],
marks: [],
};
// Set of default scales
const [starSVG] = ["M0,.5L.6,.8L.5,.1L1,-.3L.3,-.4L0,-1L-.3,-.4L-1,-.3L-.5,.1L-.6,.8L0,.5Z"];
export const symbols = [
"circle",
"square",
"triangle",
"cross",
"diamond",
starSVG,
"triangle-down",
"triangle-right",
"triangle-left",
"stroke",
"triangle-up",
"wedge",
"arrow",
];
export const scaleDefaults = [
{ scaleName: "xScale", range: "width", scaleAttr: "__x", property: "x" },
{ scaleName: "yScale", range: "height", scaleAttr: "__y", property: "y" },
{ scaleName: "fillColorScale", range: "category", scaleAttr: "__fillColor", property: "fill" },
{ scaleName: "strokeColorScale", range: "category", scaleAttr: "__strokeColor", property: "stroke" },
{ scaleName: "sizeScale", range: [4, 200], scaleAttr: "__size", property: "size" },
{ scaleName: "strokeWidthScale", range: [0, 10], scaleAttr: "__strokeWidth", property: "strokeWidth" },
{ scaleName: "shapeScale", range: symbols, scaleAttr: "__shape", property: "shape" },
{ scaleName: "opacityScale", range: [0, 1], scaleAttr: "__opacity", property: "opacity" },
{ scaleName: "fillOpacityScale", range: [0, 1], scaleAttr: "__fillOpacity", property: "fillOpacity" },
{ scaleName: "strokeOpacityScale", range: [0, 1], scaleAttr: "__strokeOpacity", property: "strokeOpacity" },
{
scaleName: "strokeDashScale",
range: [
[10, 5],
[2, 2, 5, 2],
[8, 4],
[4, 4],
[12, 3],
[6, 2],
[3, 6],
[9, 2],
[7, 5],
[5, 8],
],
scaleAttr: "__strokeDash",
property: "strokeDash",
},
];
// Define an array of objects containing the mapping logic
export const scaleTypeMapping = [
{ domainType: "numerical", rangeType: "numerical", plotType: "scatter", scaleType: "linear" },
{ domainType: "categorical", rangeType: "numerical", plotType: "default", scaleType: "point" },
{ domainType: "categorical", rangeType: "categorical", plotType: "default", scaleType: "ordinal" },
{ domainType: "numerical", rangeType: "categorical", plotType: "default", scaleType: "quantize" },
{ domainType: "categorical", rangeType: "numerical", plotType: "bar", scaleType: "band" },
{ domainType: "numerical", rangeType: "numerical", plotType: "waffle", scaleType: "band" },
{ domainType: "numerical", rangeType: "categorical", plotType: "waffle", scaleType: "quantize" },
{ domainType: "numerical", rangeType: "color", plotType: "heatmap", scaleType: "sequential" },
{ domainType: "numerical", rangeType: "color", plotType: "default", scaleType: "linear" },
{ domainType: "numerical", rangeType: "numerical", plotType: "histogram", scaleType: "quantize" },
{ domainType: "numerical", rangeType: "numerical", plotType: "default", scaleType: "linear" },
{ domainType: "temporal", rangeType: "numerical", plotType: "default", scaleType: "time" },
{ domainType: "categorical", rangeType: "numerical", plotType: "bar", scaleType: "band" },
{ domainType: "categorical", rangeType: "numerical", plotType: "point", scaleType: "band" },
{ domainType: "categorical", rangeType: "numerical", plotType: "default", scaleType: "ordinal" },
];
// Proj4 definitions of EPSG coordinate systems for use in reprojections.
export const proj4Defs = {
NAD83: "+proj=longlat +datum=NAD83 +no_defs",
EPSG4326: "+proj=longlat +datum=WGS84 +no_defs",
EPSG3857: "+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs",
};
/*
-------- HELPER FUNCTIONS FOR PARAMETER AND EXPRESSION HANDLING --------
*/
// Function to parse NodeBox parameter (=update for .fn method)
// TODO:: create proper method to check value or expression reference or expression result of input
export function fn2(nodeParamIn) {
let result;
const objRegex = new RegExp("^\\{.*\\}$");
const arrRegex = new RegExp("^\\[.*\\]$");
const litRegex = new RegExp('^".*"$');
const paramVal = nodeParamIn.value;
const paramType = nodeParamIn.node.values[nodeParamIn.name]
? nodeParamIn.node.values[nodeParamIn.name].type
: undefined;
return (data) => {
if (!paramVal) {
result = paramVal; // undefined
} else if (paramType === "VALUE" && typeof paramVal === "number") {
// number value
result = paramVal;
} else if (paramType === "VALUE" && paramVal.match(objRegex)) {
// object JSON value
result = JSON.parse(paramVal);
} else if (paramType === "VALUE" && paramVal.match(arrRegex)) {
// array JSON value
result = JSON.parse(paramVal);
} else if (paramType === "VALUE" && paramVal.match(litRegex)) {
// lit string value
result = JSON.parse(paramVal);
} else if (paramType === "VALUE" && typeof paramVal === "string") {
// string or comma seperated list
result = paramVal.split(",").map((d) => {
// Convert comma-separated value to array
if (!isNaN(Number(d))) {
return Number(d); // number values
} else if (d === "null") {
return null; // null value
} else if (d.match(litRegex)) {
return JSON.parse(d); // quoted string values
} else {
return d;
} // other
});
if (!Array.isArray(result) || result.length === 1) {
result = result[0]; // single value
} else {
result = result;
} // array
} else if (paramType === "EXPRESSION") {
result = nodeParamIn.fn(data);
}
return result;
};
}
// Function to parse Vega references to a value
export function parseVegaRef(specIn, refIn) {
let spec = structuredClone(specIn);
let ref = structuredClone(refIn);
let value;
if (ref === "width") value = [0, spec.width];
else if (ref === "height") value = [0, spec.height];
else if (ref === "padding") value = spec.padding;
else if (ref.data) value = spec.data.find((d) => d.name === ref.data).values.map((d) => d[ref.field]);
else if (ref.value) value = ref.value;
else value = ref;
return value;
}
// Function to parse NodeBox parameter to Vega references.
// TODO:: create proper method to check value or expression reference or expression result of input
export function parse2vegaRef(spec, dataName, attrName, nodeParamIn) {
// attrName is only used if nodeParamIn is a combined expression, not a single attribute or scale reference.
// Cases: undefined / value number string
const data = dataName ? spec.data.find((d) => d.name == dataName).values : undefined;
const dataTransformAs = dataName ? spec.data.find((d) => d.name == dataName)?.transform?.map((d) => d.as) || [] : [];
const dataKeys = dataName ? (data.length > 0 ? Object.keys(data[0]) : []) : [];
const scaleNames = spec.scales?.map((d) => d.name);
const paramVal = nodeParamIn.value;
const attr = attrName
? attrName
: scaleDefaults.find((d) => d.scaleName === paramVal)?.scaleAttr || "__" + paramVal + "_attr"; // default scaleAttribute added to data
let result;
const objRegex = new RegExp("^\\{.*\\}$");
const arrRegex = new RegExp("^\\[.*\\]$");
const litRegex = new RegExp('^".*"$');
const paramType = nodeParamIn.node.values[nodeParamIn.name]
? nodeParamIn.node.values[nodeParamIn.name].type
: undefined;
if (!paramVal) {
// undefined
result = undefined;
} else if (paramType === "VALUE" && typeof paramVal === "number") {
// number value
result = { value: paramVal };
} else if (paramType === "VALUE" && paramVal.match(objRegex)) {
// object JSON value
result = JSON.parse(paramVal);
} else if (paramType === "VALUE" && paramVal.match(arrRegex)) {
// array JSON value
result = JSON.parse(paramVal);
} else if (paramType === "VALUE" && paramVal.match(litRegex)) {
// lit string value
result = { value: JSON.parse(paramVal) };
} else if (paramType === "VALUE") {
// number, string, litteral value
const regex = new RegExp('^".*"$');
result = paramVal.split(",").map((d) => (!isNaN(Number(d)) ? Number(d) : d.match(regex) ? JSON.parse(d) : d)); // Convert comma-separated value to array
if (!Array.isArray(result) || result.length === 1) result = { value: result[0] };
} else if (paramType === "EXPRESSION" && paramVal.match(/"/)) {
let regex = /(?<!["'])\b[a-zA-Z_][^\s"']*\b(?!["'])/g;
// Replace matched parts with prefixed "datum."
result = paramVal.replace(regex, (match) => "datum." + match);
result = { signal: result };
} else if (paramType === "EXPRESSION" && (dataKeys.includes(attr) || dataTransformAs.includes(attr))) {
// Expression existing attribute name
result = { data: dataName, field: attr };
} else if (paramType === "EXPRESSION" && scaleNames.includes(attr)) {
// Expression existing scale name
result = { scale: paramVal, field: attr };
} else if (paramType === "EXPRESSION" && !dataKeys.includes(attr) && !dataTransformAs.includes(attr)) {
// Expression other than existing attribute name
addParamAttr(spec, dataName, attr, nodeParamIn);
result = { data: dataName, field: attr }; // New attribute name
}
return result;
}
// Function to add node input parameter values to an attribute of the spec data.
export function addParamAttr(spec, dataName, attrName, paramIn, asTransform = false) {
if (paramIn === undefined) return;
const attrList = Array.isArray(attrName) ? attrName : [attrName];
const paramList = Array.isArray(paramIn) ? paramIn : [paramIn];
if (asTransform) {
// Case: add attribute as a transform at the beginning
paramList.forEach((p, j) => {
const attr = attrList[j];
let transformExpr;
// Check if p is a literal value or node parameter
if (typeof p === "object" && "fn" in p) {
/*// If it's a node parameter, use the fn2 function to process it
const fn = fn2(p);*/
// Regular expression to match parts starting with a letter, followed by alphanumeric characters
//let regex = /(?<!["'])\b[a-zA-Z_][a-zA-Z0-9_.]*\b(?!["'])/g;
let regex = /(?<!["'])\b[a-zA-Z_][^\s"']*\b(?!["'])/g;
// Replace matched parts with prefixed "datum."
transformExpr = p.value.replace(regex, (match) => "datum." + match);
transformExpr = transformExpr.toString(); // Convert the expression to a string expression
} else {
// If it's a literal value, create a direct expression
transformExpr = JSON.stringify(p); // Convert literal to a JSON string for use in a transform
}
// Create the transform object
setPlotDataTransform({
spec,
dataName,
transformType: "formula",
params: { expr: transformExpr, as: attr },
index: -1,
});
/*const transform = {
type: "formula",
expr: transformExpr,
as: attr,
};
// Use addPlotDataTransform to add the transform at the beginning (index = 0)
addPlotDataTransform(spec, dataName, transform, 0);*/
});
} else {
// Case: modify the data directly
let data = spec.data.find((d) => d.name === dataName).values;
data.forEach((d, i) => {
paramList.forEach((p, j) => {
const attr = attrList[j];
// Check if p is a literal value or node parameter
let value;
if (typeof p === "object" && "fn" in p) {
// If it's a node parameter, use the fn2 function to process it
const fn = fn2(p);
value = fn(d);
} else {
// If it's a literal value, use it directly
value = p;
}
// Handle array values or single values for the attribute
if (Array.isArray(value)) {
d[attr] = i < value.length ? value[i] : null; // Use value cycling or null if out of bounds
} else {
d[attr] = value;
}
});
});
addPlotData(spec, data, dataName);
}
}
/*
-------- HELPER FUNCTIONS FOR VEGA SPECS --------
*/
export function applyVegaTransform({ specData, dataName, transform }) {
if (!Array.isArray(specData) || !dataName) {
throw new Error("Invalid arguments. Provide specData (array) and dataName (string).");
}
// Find the target dataset by name
const targetDataset = specData.find((d) => d.name === dataName);
if (!targetDataset || !targetDataset.values) {
throw new Error(`Dataset with name '${dataName}' not found or has no values.`);
}
// Extract relevant properties
const values = targetDataset.values;
const format = targetDataset.format;
// Prepare the data to transform based on the format
let targetData;
if (!format || format.type === "json") {
targetData = Array.isArray(values) ? values : [];
} else if (format.type === "geojson") {
if (!values.features || !Array.isArray(values.features)) {
throw new Error("Invalid GeoJSON format. 'features' array is missing.");
}
targetData = values.features.map((f) => f.properties);
} else if (format.type === "topojson") {
if (!values.objects || !values.objects[format.feature] || !values.objects[format.feature].geometries) {
throw new Error(`Feature '${format.feature}' not found in TopoJSON data.`);
}
targetData = values.objects[format.feature].geometries; //.map((g) => g.properties);
} else {
throw new Error(`Unsupported format: ${format.type}`);
}
// If no specific transform is provided, process all transforms
if (!transform) {
if (!targetDataset.transform || !Array.isArray(targetDataset.transform)) {
//throw new Error(`No transforms found for dataset '${dataName}'.`);
return specData;
}
while (targetDataset.transform.length > 0) {
const currentTransform = targetDataset.transform.shift();
targetData = applySingleTransform(specData, targetData, currentTransform);
}
} else {
// Apply a single transform
targetData = applySingleTransform(specData, targetData, transform);
}
// Update the dataset with transformed data
if (format?.type === "geojson") {
values.features.forEach((f, i) => {
f.properties = targetData[i];
});
} else if (format?.type === "topojson") {
values.objects[format.feature].geometries = targetData;
/*values.objects[format.feature].geometries.forEach((g, i) => {
g = targetData[i]; //g.properties = targetData[i]
});*/
} else {
targetDataset.values = targetData;
}
return specData;
}
export function applySingleTransform(specData, data, transform) {
switch (transform.type) {
case "formula":
return applyFormulaTransform(data, transform);
case "lookup":
return applyLookupTransform(specData, data, transform);
case "collect":
return applyCollectTransform(data, transform);
case "filter":
return applyFilterTransform(data, transform);
case "geopoint":
return data;
default:
throw new Error(`Unsupported transform type: ${transform.type}`);
}
}
export function applyFormulaTransform_old(data, transform) {
if (!transform.as || !transform.expr) {
throw new Error("'formula' transform requires 'as' and 'expr' properties.");
}
return data.map((d) => {
const newItem = { ...d };
try {
newItem[transform.as] = eval(transform.expr);
} catch (err) {
console.error(`Error evaluating formula: ${err.message}`);
newItem[transform.as] = null;
}
return newItem;
});
}
export function applyFormulaTransform_old2(data, transform) {
if (!transform.as || !transform.expr) {
throw new Error("'formula' transform requires 'as' and 'expr' properties.");
}
// Parse the Vega expression into an executable function
const parsedExpression = parseExpression(transform.expr).code;
const evaluateExpression = new Function("datum", `"use strict";\nreturn (${parsedExpression});`);
return data.map((d) => {
const newItem = { ...d };
try {
// Evaluate the Vega expression with the current datum
newItem[transform.as] = evaluateExpression(d);
} catch (err) {
console.error(`Error evaluating formula: ${err.message}`);
newItem[transform.as] = null;
}
return newItem;
});
}
export function applyFormulaTransform(data, transform) {
if (!transform.as || !transform.expr) {
throw new Error("'formula' transform requires 'as' and 'expr' properties.");
}
// Create a function to evaluate the expression
const evaluateExpression = new Function("datum", `"use strict";\nreturn (${transform.expr});`);
return data.map((d) => {
const newItem = { ...d };
try {
// Evaluate the Vega expression with the current datum
newItem[transform.as] = evaluateExpression(d);
} catch (err) {
console.error(`Error evaluating formula: ${err.message}`);
newItem[transform.as] = null;
}
return newItem;
});
}
export function applyLookupTransform(specData, data, transform) {
if (!transform.from || !transform.key || !transform.fields || !transform.values) {
throw new Error("'lookup' transform requires 'from', 'key', 'fields', and 'values' properties.");
}
// Find the source dataset
const sourceDataset = specData.find((d) => d.name === transform.from);
if (!sourceDataset || !sourceDataset.values) {
throw new Error(`Source dataset '${transform.from}' not found or has no values.`);
}
const sourceData = sourceDataset.values;
// Build a lookup map
const lookupMap = {};
sourceData.forEach((item) => {
lookupMap[item[transform.key]] = item;
});
return data.map((d) => {
const newItem = { ...d };
transform.fields.forEach((field, index) => {
const lookupValue = lookupMap[getNestedProperty(newItem, field)];
if (lookupValue) {
transform.values.forEach((valueField, i) => {
const newAttr = transform.as ? transform.as[i] : valueField;
newItem[newAttr] = lookupValue[valueField];
});
}
});
return newItem;
});
}
export function applyCollectTransform(data, transform) {
if (!transform.sort || !Array.isArray(transform.sort.field)) {
throw new Error("'collect' transform requires a 'sort' property with a 'field' array.");
}
const { field, order = "ascending" } = transform.sort;
return [...data].sort((a, b) => {
for (let i = 0; i < field.length; i++) {
const key = field[i];
const dir = Array.isArray(order) ? order[i] : order;
const aValue = a[key];
const bValue = b[key];
if (aValue !== bValue) {
if (dir === "ascending") {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
}
}
return 0;
});
}
export function applyFilterTransform_old(data, transform) {
if (!transform.expr) {
throw new Error("'filter' transform requires an 'expr' property.");
}
const parsedExpression = parseExpression(transform.expr).code;
const evaluateExpression = new Function("datum", `"use strict";\nreturn (${parsedExpression});`);
return data.filter((d) => {
try {
return evaluateExpression(d);
} catch (err) {
console.error(`Error evaluating filter expression: ${err.message}`);
return false;
}
});
}
export function applyFilterTransform(data, transform) {
if (!transform.expr) {
throw new Error("'filter' transform requires an 'expr' property.");
}
// Create a function to evaluate the expression
const evaluateExpression = new Function("datum", `"use strict";\nreturn (${transform.expr});`);
// Filter the data
return data.filter((d) => {
try {
const result = evaluateExpression(d);
return result; // Include only rows where the expression evaluates to true
} catch (err) {
console.error(`Error evaluating filter expression: ${err.message}`);
return false; // Exclude rows with evaluation errors
}
});
}
/*
-------- HELPER FUNCTIONS FOR NESTED DATA --------
*/
// Function to filter out undefined object properties
export function deleteUndefined(object) {
if (Array.isArray(object)) object.forEach(deleteUndefined);
if (object)
Object.keys(object).forEach((key) => {
if (object[key] === undefined) delete object[key];
else if (typeof object[key] === "object") deleteUndefined(object[key]);
});
}
// Function to filter out undefined object properties
export function deleteNull(object) {
if (Array.isArray(object)) object.forEach(deleteUndefined);
if (object)
Object.keys(object).forEach((key) => {
if (object[key] === null) delete object[key];
else if (typeof object[key] === "object") deleteNull(object[key]);
});
}
// Function to merge nested objects
export function deepAssign(target, ...sources) {
sources.forEach((source) => {
if (source && typeof source === "object") {
Object.keys(source).forEach((key) => {
if (source[key] && typeof source[key] === "object") {
if (!target[key] || typeof target[key] !== "object") {
target[key] = Array.isArray(source[key]) ? [] : {};
}
deepAssign(target[key], source[key]);
} else {
target[key] = source[key];
}
});
}
});
return target;
}
// Function to clean nested vega objects
export function deepCleanVega(spec) {
//spec.forEach((obj) => {
let obj = spec;
if (obj && typeof obj === "object") {
const keys = Object.keys(obj);
if (keys[0] === "value" && keys.includes("field")) {
delete obj.value;
} else if (keys[keys.length - 1] === "value" && keys.includes("field")) {
delete obj.field;
delete obj.scale;
}
if (keys.includes("scale") && keys.includes("data")) {
delete obj.data;
}
keys.forEach((key) => {
if (obj[key] && typeof obj[key] === "object") {
deepCleanVega(obj[key]);
}
});
}
//});
return spec;
}
// Helper function to get nested properties using a string path.
export function getNestedProperty(obj, path) {
if (obj === null || typeof obj !== "object") {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(obj, path)) {
return obj[path];
}
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || typeof current !== "object") {
return undefined;
}
current = current[key];
}
return current;
}
// Helper function to get properties from nested file formats
export function getAttributeValues({ data, format = "json", feature }) {
if (!data) {
throw new Error("Data must be provided.");
}
let dataVals = structuredClone(data);
if (format === "json") {
// Return the input array of objects as is for JSON format
if (Array.isArray(dataVals)) {
return dataVals;
} else {
throw new Error("For JSON format, data must be an array of objects.");
}
} else if (format === "geojson") {
// Extract and return properties from GeoJSON features
if (dataVals.features && Array.isArray(dataVals.features)) {
return dataVals.features.map((f) => f.properties);
} else {
throw new Error("Invalid GeoJSON data. 'features' key is missing or invalid.");
}
} else if (format === "topojson") {
// Extract and return properties from TopoJSON
if (!feature) {
throw new Error("For TopoJSON format, a feature name must be specified.");
}
if (dataVals.objects && dataVals.objects[feature] && dataVals.objects[feature].geometries) {
return dataVals.objects[feature].geometries; //.map((g) => g.properties || {});
} else {
throw new Error(`Feature '${feature}' not found in TopoJSON data.`);
}
} else {
throw new Error(`Unsupported format: ${format}`);
}
}
/*
-------- HELPER FUNCTIONS FOR AUTO SCALES --------
*/
// Helper function to determine the type of the domain
export function determineDomainType(values) {
// Filter out undefined and null values
const filteredVals = Array.isArray(values)
? values.filter((val) => val !== undefined && val !== null)
: values !== undefined && values !== null
? [values]
: [];
if (filteredVals.every((val) => typeof val === "number")) {
return "numerical";
} else if (filteredVals.every((val) => Object.prototype.toString.call(val) === "[object Date]")) {
return "temporal";
} else if (filteredVals.every((val) => typeof val === "string")) {
return "categorical";
} else {
return "numerical"; // Default to numerical if mixed types
}
}
// Helper function to determine the type of the range
export function determineRangeType(property) {
const propertyTypes = {
numerical: ["x", "y", "size", "strokeWidth", "opacity", "fillOpacity", "strokeOpacity"],
categorical: ["shape", "fill", "stroke", "strokeDash", "gridUnits"],
};
if (propertyTypes.numerical.includes(property)) {
return "numerical";
} else if (propertyTypes.categorical.includes(property)) {
return "categorical";
/*} else if (values.every(val => typeof val === 'string' && /^#[0-9A-F]{6}$/i.test(val))) {
return "color";*/
} else {
throw new Error("Unable to determine range type");
}
}
// Function to find the best scale type from the mapping
export function findScaleType(domainType, rangeType, plotType) {
const match = scaleTypeMapping.find(
(mapping) =>
mapping.domainType === domainType &&
mapping.rangeType === rangeType &&
(mapping.plotType === plotType || mapping.plotType === "default"),
);
if (match) {
return match.scaleType;
} else {
return "linear";
//throw new Error("No matching scale type found");
}
}
// Function to determine the optimal vega scale type.
export function selectVegaScaleType(domainValues, property, plotType) {
// Determine domain and range types
const domainType = determineDomainType(domainValues);
const rangeType = determineRangeType(property);
// Determine the best scale type based on input parameters
return findScaleType(domainType, rangeType, plotType);
}
/*
-------- HELPER FUNCTIONS FOR D3 SCALES --------
*/
// Custom quantize function to fit range.
export function scaleQuantizeFit(domain, range) {
let [d0, d1] = domain.map(Number); // Coerce domain elements to numbers
const rangeSize = range.length; // Length of the range (e.g., 60)
const binWidth = (d1 - d0) / rangeSize;
const thresholds = d3range(d0, d1, binWidth).slice(1, -1);
// Function to create a bin generator with current thresholds
function createBinGenerator() {
return bin().domain([d0, d1]).thresholds(thresholds);
}
// Internal bin generator using d3.bin()
let binGenerator = createBinGenerator();
// Internal quantize function
function quantizeFit(value) {
value = +value; // Coerce input value to a number
// Clamp value to range
if (value <= d0) return range[0];
if (value >= d1) return range[rangeSize - 1];
// Generate bins for the input value and use the bin index
const bins = binGenerator([value]);
// `bins` will contain an array of bin thresholds, we find the correct bin
const binIndex = bins.findIndex((bin) => bin.x0 <= value && value < bin.x1);
// Return the corresponding range value
return range[binIndex];
}
// Function to normalize values, bin them, and adjust the bins
function fitValues(values) {
// bin fit values
const bins = binGenerator(values);
// Get the bin counts
let binCounts = bins.map((bin, i) => (bin.length ? i + 1 : null)).filter((d) => d != null);
// Calculate total bin count
let flooredSum = binCounts.reduce((acc, val) => acc + val, 0);
let totalAdjustment = rangeSize - flooredSum; // Difference to adjust to match rangeSize
// Adjust thresholds to balance the bin counts
let fractionalParts = values.map((v) => v % binWidth);
let fractionOrder = fractionalParts
.map((part, index) => index)
.sort((a, b) => fractionalParts[b] - fractionalParts[a]);
let threshAdjust, fractionIndex, binIndex;
for (let i = 0; i < Math.abs(totalAdjustment); i++) {
if (totalAdjustment < 0) {
// Add smallest fractional parts to resp. threshold (to fit in previous bin)
fractionIndex = fractionOrder[fractionalParts.length - 1 - i];
binIndex = Math.floor(values[fractionIndex] / binWidth);
threshAdjust = fractionalParts[fractionIndex];
thresholds[binIndex - 1] += threshAdjust + 1;
} else if (totalAdjustment > 0) {
// Subtract largest fractional parts from resp. threshold (to fit in next bin)
fractionIndex = fractionOrder[i];
binIndex = Math.floor(values[fractionIndex] / binWidth);
threshAdjust = fractionalParts[fractionIndex];
thresholds[binIndex] -= binWidth - threshAdjust;
}
binGenerator = createBinGenerator();
}
}
// Method to get/set domain
quantizeFit.domain = function (newDomain) {
if (!arguments.length) return domain;
domain = newDomain.map(Number); // Coerce domain elements to numbers
[d0, d1] = domain;
return quantizeFit;
};
// Method to get/set range
quantizeFit.range = function (newRange) {
if (!arguments.length) return range;
range = newRange; // Allow range to accept any values
return quantizeFit;
};
// Method to fit the values and adjust bins
quantizeFit.fit = function (values) {
fitValues(values);
return quantizeFit;
};
// Method to invert extent
quantizeFit.invertExtent = function (y) {
const i = range.indexOf(y);
return i === -1 ? [NaN, NaN] : [d0 + (i / rangeSize) * (d1 - d0), d0 + ((i + 1) / rangeSize) * (d1 - d0)];
};
// Method to copy the scale
quantizeFit.copy = function () {
return scaleQuantizeFit(domain.slice(), range.slice());
};
return quantizeFit;
}
// Function to create D3 scale using Vega parameters
export function parseVegaScale(
specIn,
{
name,
type,
domain,
domainMax,
domainMin,
domainMid,
domainRaw,
interpolate,
range,
reverse,
round,
bins,
clamp,
padding,
nice,
zero,
base,
exponent,
constant,
align,
domainImplicit,
paddingInner,
paddingOuter,
},
) {
const spec = structuredClone(specIn);
let scaleFncs = {
linear: scaleLinear,
log: scaleLog,
pow: scalePow,
sqrt: scaleSqrt,
symlog: scaleSymlog,
time: scaleTime,
utc: scaleUtc,
ordinal: scaleOrdinal,
band: scaleBand,
point: scalePoint,
diverging: scaleDiverging,
quantile: scaleQuantile,
quantize: scaleQuantize,
threshold: scaleThreshold,
quantizeFit: scaleQuantizeFit,
};
let scale = scaleFncs[type](parseVegaRef(spec, domain), parseVegaRef(spec, range));
if (domainMin && domainMax) scale.domain([parseVegaRef(spec, domainMin), parseVegaRef(spec, domainMax)]);
if (domainRaw) scale.domain(parseVegaRef(spec, domainRaw));
if (interpolate) scale.interpolate(parseVegaRef(spec, interpolate));
if (reverse) scale.range(parseVegaRef(spec, range).reverse());
if (round) scale.round(parseVegaRef(spec, round));
if (["linear", "log", "pow", "sqrt", "symlog", "time", "utc"].includes(type)) {
if (clamp) scale.clamp(parseVegaRef(spec, clamp));
if (nice) scale.nice();
}
if (bins) scale.bins(bins);
if (base) scale.base(base);
if (exponent) scale.exponent(exponent);
if (constant) scale.constant(constant);
if (align && ["band", "point"].includes(type)) scale.align(align);
if (padding && ["band", "point"].includes(type)) scale.padding(padding);
if (paddingInner && type === "band") scale.paddingInner(paddingInner);
if (paddingOuter && type === "band") scale.paddingOuter(paddingOuter);
return scale;
}
/*
-------- DATA HANDLING FUNCTIONS --------
*/
// Enhanced function to handle single or multiple attribute grouping
export function sumRollup(data, groupBy, sumAttr, keep) {
// Determine the key function based on whether groupBy is a single string or an array of strings
const keyFunction = Array.isArray(groupBy)
? (d) => groupBy.map((attr) => d[attr]).join("|") // Join multiple attributes with a separator to form a composite key
: (d) => d[groupBy]; // Single attribute grouping
// Perform the rollup using d3.rollup with the dynamic key function
let rolledUpData = structuredClone(data);
rolledUpData = rollup(
rolledUpData,
(v) => {
const result = {};
// Keep attribute(s) in the result
if (Array.isArray(keep)) {
keep.forEach((attr) => (result[attr] = v[0][attr]));
} else {
result[keep] = v[0][keep];
}
// Keep only the groupBy attribute(s) in the result
if (Array.isArray(groupBy)) {
groupBy.forEach((attr) => (result[attr] = v[0][attr]));
} else {
result[groupBy] = v[0][groupBy];
}
result[sumAttr] = sum(v, (d) => d[sumAttr]); // Aggregate sum of the specified attribute
return result;
},
keyFunction,
);
// Convert the Map result to an array of objects
const resultArray = Array.from(rolledUpData, ([key, value]) => ({
...value,
}));
return resultArray;
}
/*
-------- HELPER FUNCTIONS FOR GRID PLOTS --------
*/
// Function to calculate grid size of waffle plot
export function calculateGridSize({ data, nCols, nRows, groupBy, offset, direction, gridProp }) {
//let nDataRecords = data.length;
let nUnits = data.map((d) => d[gridProp]).reduce((acc, curr) => acc + curr, 0);
let nCols_calc = structuredClone(nCols);
let nRows_calc = structuredClone(nRows);
// Calculate the grid size based on direction and provided dimensions
if (!nCols && !nRows) {
nCols_calc = Math.ceil(Math.sqrt(nUnits));
nRows_calc = Math.ceil(nUnits / nCols_calc);
} else if (nCols && !nRows) {
nRows_calc = Math.ceil(nUnits / nCol_calc);
} else if (nRows && !nCols) {
nCols_calc = Math.ceil(nUnits / nRows_calc);
} /*else {
if (direction === 'X' && nCols * nRows < nDataRecords) {
nRows = Math.ceil(nDataRecords / nCols);
} else if (direction === 'Y' && nCols * nRows < nDataRecords) {
nCols = Math.ceil(nDataRecords / nRows);
}
}*/
return [nCols_calc, nRows_calc, nUnits];
}
// Function to add grid position coördinates
// offset normalize converts absolute values into relative values
export function addGridData({
spec,
dataName,
nCols,
nRows,
gridScaleName,
groupBy,
unitRatio = 1,
offset,
direction,
sortAttr,
order,
}) {
// Get spec data
let data = spec.data.find((d) => d.name === dataName);
if (!data || !data.values) return; // Return if no data found
const gridDataName = "gridTable";
const gridProp = "__gridUnits";
// Add gridUnit attribute to data
let fn;
if (typeof unitRatio === "object" && "fn" in unitRatio) {
// If it's a node parameter, use the fn2 function to process it
fn = fn2(unitRatio);
} else {
// If it's a literal value, use it directly
fn = (d) => {
return unitRatio;
};
}
data.values.forEach((d, i) => {
d[gridProp] = fn(d);
});
addPlotData(spec, data.values, dataName);
let dataValues = structuredClone(data.values);
//let nDataRecords = dataValues.length;
let nCols_calc, nRows_calc, nUnits;
// Sort data values if sorting is specified
if (sortAttr) {
let sortFn;
if (order === "ascending" || order === undefined) {
sortFn = (a, b) => ascending(a[sortAttr], b[sortAttr]);
} else {
sortFn = (a, b) => descending(a[sortAttr], b[sortAttr]);
}
dataValues.sort(sortFn);
}
// Calculate grid size
[nCols_calc, nRows_calc, nUnits] = calculateGridSize({
data: dataValues,
nCols: nCols,
nRows: nRows,
groupBy: groupBy,
offset: offset,
direction: direction,
gridProp: gridProp,
});
const totalCells = nCols_calc * nRows_calc;
// Calculate grid domain
const propKeys = Object.keys(dataValues[0]).filter((key) => key.startsWith("__"));
//const rollupKeys = Array.isArray(groupBy) ? [...groupBy, ...propKeys] : [groupBy, ...propKeys];
const groupedData = groupBy ? sumRollup(dataValues, groupBy, gridProp, propKeys) : dataValues;
const domainTotal = sum(groupedData.map((d) => d[gridProp]));
// Adjust domain based on user-defined unitRatio
const gridDomainMin = 0;
const gridDomainMax = domainTotal; // Scale the domain based on the unitRatio
const gridDomain = [gridDomainMin, gridDomainMax];
// Calculate range
let gridRange = d3range(1, Math.ceil(gridDomainMax) + 1, 1);
if (offset === "normalize") {
gridRange = d3range(1, totalCells + 1, 1);
}
// Create and parse gridScale to calculate unit number.
let gridScale = {
name: "gridUnitScale",
type: "quantizeFit",
domain: gridDomain,
range: gridRange,
};
let gridD3Scale = parseVegaScale(spec, gridScale);
const fitData = groupedData.map((d) => d[gridProp]);
// fit scale and count total
let totalScaled;
if (offset === "normalize") {
gridD3Scale.fit(fitData);
totalScaled = fitData.map((d) => gridD3Scale(d)).reduce((acc, curr) => acc + curr);
} else if (typeof unitRatio === "object" && "fn" in unitRatio) {
const paramType = unitRatio.node.values[unitRatio.name] ? unitRatio.node.values[unitRatio.name].type : undefined;
if (paramType === "EXPRESSION") {
gridD3Scale.fit(fitData);
totalScaled = fitData.map((d) => gridD3Scale(d)).reduce((acc, curr) => acc + curr);
} else {
totalScaled = fitData.reduce((acc, curr) => acc + curr);
}
} else {
totalScaled = fitData.reduce((acc, curr) => acc + curr);
}
// Create grid data
let gridData = [];
let cellIdx = 0;
groupedData.forEach((elem) => {
// for each record
// set unitCount
let unitCnt;
if (offset === "normalize") {
unitCnt = gridD3Scale(elem[gridProp]);
} else if (typeof unitRatio === "object" && "fn" in unitRatio) {
const paramType = unitRatio.node.values[unitRatio.name] ? unitRatio.node.values[unitRatio.name].type : undefined;
if (paramType === "EXPRESSION") {
unitCnt = gridD3Scale(elem[gridProp]);
} else {
unitCnt = elem[gridProp];
}
} else {
unitCnt = elem[gridProp];
}
for (let i = 0; i < unitCnt && cellIdx < totalCells; i++) {
// for each available unit
if (cellIdx === totalCells) {
break;
} else {
// Calculate __x and __y based on the direction
const newElem = structuredClone(elem);
newElem.__x = direction === "X" ? Math.floor(cellIdx / nRows) : cellIdx % nCols;
newElem.__y = direction === "X" ? cellIdx % nRows : Math.floor(cellIdx / nCols);
newElem.__plot = true;
// Apply center offset adjustments if needed
if (offset === "center") {
if (direction === "Y" && newElem.__y === Math.ceil(totalScaled / nCols_calc) - 1) {
if (nCols_calc - (totalScaled % nCols_calc) !== nCols_calc) {
newElem.__x += Math.floor((nCols_calc - (totalScaled % nCols_calc)) / 2);
}
} else if (direction === "X" && newElem.__x === Math.ceil(totalScaled / nRows_calc) - 1) {
if (nRows_calc - (totalScaled % nRows_calc) !== nRows_calc) {
newElem.__y += Math.floor((nRows_calc - (totalScaled % nRows_calc)) / 2);
}
}
}
gridData.push(newElem);
cellIdx++;
}
}
});
addPlotData(spec, gridData, gridDataName);
// Copy transforms from the original data spec to the gridTable spec
let originalTransforms = data.transform ? structuredClone(data.transform) : [];
spec.data.find((d) => d.name === gridDataName).transform = originalTransforms;
}
/*
-------- HELPER FUNCTIONS FOR GEOGRAPHIC PLOTS --------
*/
// Reprojection functions
export function reprojectCoordinates(geoData, chosenCRS) {
const targetProj = proj4Defs.EPSG4326;
if (chosenCRS === "EPSG4326") {
return geoData;
}
const sourceProj = proj4Defs[chosenCRS.replace(":", "")];
if (!sourceProj) {
throw new Error(`Unsupported CRS: ${chosenCRS}`);
return geoData;
}
geoData.forEach((feature) => {
if (feature.geometry && feature.geometry.coordinates) {
feature.geometry.coordinates = transformCoordinates(feature.geometry.coordinates, sourceProj, targetProj);
}
});
return geoData;
}
// Function to transform coordinates used in reprojection function
export function transformCoordinates(coordinates, sourceProj, targetProj) {
if (Array.isArray(coordinates[0])) {
return coordinates.map((coord) => transformCoordinates(coord, sourceProj, targetProj));
} else {
return proj4(sourceProj, targetProj, coordinates);
}
}
// Object to lookup d3 projections
export const d3geo = {
geoAlbers: geoAlbers,
geoAlbersUsa: geoAlbersUsa,
geoAzimuthalEqualArea: geoAzimuthalEqualArea,
geoAzimuthalEquidistant: geoAzimuthalEquidistant,
geoConicConformal: geoConicConformal,
geoConicEqualArea: geoConicEqualArea,
geoConicEquidistant: geoConicEquidistant,
geoEquirectangular: geoEquirectangular,
geoGnomonic: geoGnomonic,
geoMercator: geoMercator,
geoOrthographic: geoOrthographic,
geoStereographic: geoStereographic,
geoTransverseMercator: geoTransverseMercator,
geoNaturalEarth1: geoNaturalEarth1,
};
// Helper function to apply the projection to coordinates or its inverse
export function applyD3Projection(longitude, latitude, projection, invert = false) {
// Helper function to capitalize projection type for D3 compatibility
function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
// Use the D3 projection engine
const d3Projection = d3geo[`geo${capitalize(projection.type)}`]();
if (projection.center && d3Projection.center) d3Projection.center(projection.center);
if (projection.scale && d3Projection.scale) d3Projection.scale(projection.scale);
if (projection.translate && d3Projection.translate) d3Projection.translate(projection.translate);
if (projection.rotate && d3Projection.rotate) d3Projection.rotate(projection.rotate);
// Apply the projection or its inverse
if (invert) {
return d3Projection.invert([longitude, latitude]);
} else {
return d3Projection([longitude, latitude]);
}
}
// Function to calculate the geographic extent of the map
export function calculateMapExtent(projection, width, height) {
// Plot figure corners
const topLeft = [0, 0]; // Top-left corner of the map
const bottomRight = [width, height]; // Bottom-right corner of the map
// Get geographic coordinates for the corners
const topLeftGeo = applyD3Projection(topLeft[0], topLeft[1], projection, true);
const bottomRightGeo = applyD3Projection(bottomRight[0], bottomRight[1], projection, true);
// Return the geographic extent
return {
minLongitude: topLeftGeo[0],
maxLongitude: bottomRightGeo[0],
minLatitude: bottomRightGeo[1],
maxLatitude: topLeftGeo[1],
};
}
// Function to detect or validate data format (json, geojson, topojson)
export function detectDataFormat(data) {
if (!data) return undefined;
// Extract single dataObject for geojson and json
let dataObj;
if (Array.isArray(data)) {
if (data.length === 1) {
dataObj = data[0];
} else {
return "json";
}
} else {
dataObj = data;
}
// GeoJSON Member types
const geojsonTypes = [
"Point",
"MultiPoint",
"LineString",
"MultiLineString",
"Polygon",
"MultiPolygon",
"GeometryCollection",
"Feature",
"FeatureCollection",
];
// TopoJSON Member types
const topojsonGeometryTypes = [
"Point",
"MultiPoint",
"LineString",
"MultiLineString",
"Polygon",
"MultiPolygon",
"GeometryCollection",
];
// Check data format
if (dataObj && "type" in dataObj) {
if (geojsonTypes.includes(type)) {
// validate geojson
switch (type) {
case "Point":
case "MultiPoint":
if ("coordinates" in dataObj && Array.isArray(dataObj.coordinates)) {
return `geojson ${type.toLowerCase()}`;
}
break;
case "LineString":
case "MultiLineString":
case "Polygon":
case "MultiPolygon":
if ("coordinates" in dataObj && Array.isArray(dataObj.coordinates)) {
return `geojson ${type.toLowerCase()}`;
} else if ("arcs" in dataObj && Array.isArray(dataObj.arcs)) {
return `topojson ${type.toLowerCase()}`;
}
break;
case "GeometryCollection":
if ("geometries" in dataObj && Array.isArray(dataObj.geometries)) {
if (dataObj.geometries.every((geometry) => detectDataFormat(geometry).startsWith("geojson"))) {
return "geojson geometrycollection";
} else if (dataObj.geometries.some((geometry) => detectDataFormat(geometry).startsWith("topojson"))) {
return "topojson geometrycollection";
}
}
break;
case "Feature":
if (
"geometry" in dataObj &&
"properties" in dataObj &&
(dataObj.geometry === null || detectDataFormat(dataObj.geometry).startsWith("geojson"))
) {
return "geojson feature";
}
break;
case "FeatureCollection":
if (
"features" in dataObj &&
Array.isArray(dataObj.features) &&
dataObj.features.every((feature) => detectDataFormat(feature) === "geojson feature")
) {
return "geojson featurecollection";
}
break;
default:
break;
}
} else if (type === "Topology") {
// validate topojson
if ("objects" in dataObj && "arcs" in dataObj) {
if (typeof dataObj.objects === "object" && Array.isArray(dataObj.arcs)) {
// Validate objects in the topology
for (const key in dataObj.objects) {
const geometryObj = dataObj.objects[key];
if (topojsonGeometryTypes.includes(geometryObj.type)) {
const format = detectDataFormat(geometryObj);
if (!format.startsWith("geojson") && !format.startsWith("topojson")) {
console.warn(`Invalid GeoJSON member in TopoJSON object: ${key}`);
} else {
return "topojson topology";
}
}
}
} else {
console.warn("Invali