UNPKG

@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
/* * 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