@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
186 lines (168 loc) • 7.1 kB
JavaScript
/**
* Combines the records of two datasets based on common attributes.
*
* The join parameter defines the type of combination of the two datasets.
*
* - inner: Returns only records that have matching values in both datasets.
* - left outer: Returns all records from the left dataset, and the matched records from the right dataset.
* - right outer: Returns all records from the right dataset, and the matched records from the left dataset.
* - full outer: Returns all matched and not-matched and unmatched records of both left and right datasets.
* - cross: Matches each record of the right dataset with each record of the left dataset, regardless of the matching keys.
*
* The optional select parameter allows to pass a JavaScript function
* to define which attributes (or derivatives) to keep in the resulting joined dataset.
*
* @category Data Manipulation
*/
import { detectDataFormat, getNestedProperty, extractCoreData, replaceCoreData } from "project:Utilities";
export default function (node) {
node.pushSection({ name: "General" });
const leftIn = node.tableIn({ name: "dataLeft", label: "Left data" });
const rightIn = node.tableIn({ name: "dataRight", label: "Right data" });
const modeIn = node.stringIn({
name: "mode",
label: "Mode",
value: "inner",
choices: ["inner", "left outer", "right outer", "full outer", "cross"],
});
const lkeyIn = node.stringIn({ name: "leftKey", label: "Left key(s)" });
const rkeyIn = node.stringIn({ name: "rightKey", label: "Right key(s)" });
const selectIn = node.stringIn({ name: "select", label: "Select", widget: "TEXT", value: "return { ...d };" });
node.popSection();
node.pushSection({ name: "Data formats", collapsed: true });
const leftFormatIn = node.stringIn({
name: "leftFormat",
label: "Left format",
value: "json",
choices: [
["json", "<default>"],
["geojson", "GeoJSON"],
["topojson", "TopoJSON"],
],
});
const leftFeatureIn = node.stringIn({
name: "leftFeature",
label: "Left feature",
});
const leftPropertiesIn = node.booleanIn({
name: "leftProperties",
label: "Use properties (left)",
value: true,
});
const rightFormatIn = node.stringIn({
name: "rightFormat",
label: "Right format",
value: "json",
choices: [
["json", "<default>"],
["geojson", "GeoJSON"],
["topojson", "TopoJSON"],
],
});
const rightFeatureIn = node.stringIn({
name: "rightFeature",
label: "Right feature",
});
const rightPropertiesIn = node.booleanIn({
name: "rightProperties",
label: "Use properties (right)",
value: true,
});
node.popSection();
const dataOut = node.tableOut({ name: "dataOut", label: "Data" });
function joinData(left, right, lkey, rkey, mode, selectFn) {
const output = [];
if (!Array.isArray(left)) {
throw new Error(`Left data is not an array. Detected format is ${detectDataFormat(left)}.`);
}
if (!Array.isArray(right)) {
throw new Error(`Right data is not an array. Detected format is ${detectDataFormat(right)}.`);
}
if (mode === "cross") {
left.forEach((leftRow) => {
right.forEach((rightRow) => {
const joinedRow = { ...leftRow, ...rightRow };
output.push(selectFn(joinedRow));
});
});
} else {
const leftKeyArray = lkey.split(",").map((d) => d.trim());
const rightKeyArray = rkey.split(",").map((d) => d.trim());
// Helper to generate a composite key from a row. If any key is missing (undefined or null),
// we return null so that this row is treated as "unmatched" rather than producing the string
// "undefined" and accidentally colliding with other rows that miss the same field.
const keyFn = (row, keys) => {
const values = keys.map((k) => getNestedProperty(row, k));
return values.some((v) => v === undefined || v === null) ? null : values.join("|");
};
// Build lookup table for the *right* dataset. Rows that have an invalid (null) key are not
// placed in the lookup to avoid unintended matches, but they can still be emitted later for
// right/full outer joins.
const lookup = new Map();
const unmatchedRight = [];
right.forEach((row) => {
const key = keyFn(row, rightKeyArray);
if (key === null) {
unmatchedRight.push(row);
return;
}
if (!lookup.has(key)) {
lookup.set(key, []);
}
lookup.get(key).push(row);
});
// Process the *left* dataset and join where possible.
left.forEach((leftRow) => {
const key = keyFn(leftRow, leftKeyArray);
const rightRows = key !== null ? lookup.get(key) || [] : [];
if (rightRows.length > 0) {
rightRows.forEach((rightRow) => {
const joinedRow = { ...leftRow, ...rightRow };
output.push(selectFn(joinedRow));
});
} else if (mode === "left outer" || mode === "full outer") {
// No match found – keep the left row as-is (outer-join behaviour)
output.push(selectFn({ ...leftRow }));
}
});
// Add rows from the right side that never matched a left row (right/full outer).
if (mode === "right outer" || mode === "full outer") {
// Rows that lacked a valid key were already collected in `unmatchedRight`.
const rightCandidates = [...unmatchedRight];
// Also include rows with a key that is not present in any left row.
lookup.forEach((rows, key) => {
const hasMatchInLeft = left.some((leftRow) => keyFn(leftRow, leftKeyArray) === key);
if (!hasMatchInLeft) {
rightCandidates.push(...rows);
}
});
rightCandidates.forEach((row) => output.push(selectFn({ ...row })));
}
}
return output;
}
node.onRender = async () => {
const leftData = structuredClone(leftIn.value);
const rightData = structuredClone(rightIn.value);
const leftFeature = leftFeatureIn.value === "" ? undefined : leftFeatureIn.value;
const leftFormat = leftFormatIn.value === "" ? undefined : leftFormatIn.value;
const rightFeature = rightFeatureIn.value === "" ? undefined : rightFeatureIn.value;
const rightFormat = rightFormatIn.value === "" ? undefined : rightFormatIn.value;
const left = extractCoreData(leftData, leftFormat, leftFeature, leftPropertiesIn.value);
const right = extractCoreData(rightData, rightFormat, rightFeature, rightPropertiesIn.value);
const mode = modeIn.value;
const lkey = lkeyIn.value;
const rkey = rkeyIn.value;
if (!left || !right) {
dataOut.set([]);
return;
} else if (mode != "cross" && (!lkey || !rkey)) {
dataOut.set([]);
return;
}
const selectFn = new Function("d", selectIn.value);
const joined = joinData(left, right, lkey, rkey, mode, selectFn);
const newLeft = replaceCoreData(leftData, leftFormat, leftFeature, joined, leftPropertiesIn.value);
dataOut.set(newLeft);
};
}