Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 144 additions & 2 deletions src/duckdb/src/table/duckdb-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,45 @@
*/
const KEPLER_GEOM_FROM_GEOJSON_COLUMN = '_geojson';

/**
* Check if a GeoJSON FeatureCollection contains XYZM (4D) coordinates.
* This is used to detect trip data where the 4th coordinate is a timestamp.
* @param geojson The GeoJSON FeatureCollection to check
* @returns true if the GeoJSON contains 4D coordinates
*/
function hasXYZMCoordinates(geojson: any): boolean {
if (!geojson || geojson.type !== 'FeatureCollection' || !Array.isArray(geojson.features)) {
return false;
}

for (const feature of geojson.features) {
const coords = feature?.geometry?.coordinates;
if (!coords) continue;

const geomType = feature?.geometry?.type;

if (geomType === 'LineString' && Array.isArray(coords)) {
// LineString: [[x,y,z,m], [x,y,z,m], ...]
if (coords.length > 0 && Array.isArray(coords[0]) && coords[0].length >= 4) {
return true;
}
} else if (geomType === 'MultiLineString' && Array.isArray(coords)) {
// MultiLineString: [[[x,y,z,m], [x,y,z,m], ...], ...]
if (
coords.length > 0 &&
Array.isArray(coords[0]) &&
coords[0].length > 0 &&
Array.isArray(coords[0][0]) &&
coords[0][0].length >= 4
) {
return true;
}
}
}

return false;
}
Comment thread
lixun910 marked this conversation as resolved.
Outdated

/**
* Names of columns that most likely contain binary wkb geometry
*/
Expand Down Expand Up @@ -139,7 +178,18 @@
async importGeoJsonData({data, db, c}: ImportDataToDuckProps): Promise<ImportDataToDuckResult> {
try {
const {rows} = data;
await db.registerFileText(this.id, JSON.stringify(rows));
const geojsonStr = JSON.stringify(rows);

// Check if the GeoJSON has XYZM (4D) coordinates (e.g., trip data with timestamps)
const has4DCoords = hasXYZMCoordinates(rows);

if (has4DCoords) {
console.log('[importGeoJsonData] Detected XYZM coordinates, using custom read_json parsing');
return await this.importGeoJsonWithXYZM({data, db, c, geojsonStr});
}

// Standard ST_READ path for non-XYZM GeoJSON
await db.registerFileText(this.id, geojsonStr);

const createTableSql = `
install spatial;
Expand All @@ -157,7 +207,99 @@
}

return {
// _geojson column is created from geometry with keep_wkb flag and contains valid WKB data.
geoarrowMetadata: {[KEPLER_GEOM_FROM_GEOJSON_COLUMN]: GEOARROW_EXTENSIONS.WKB}
};
}

/**
* Import GeoJSON with XYZM (4D) coordinates using custom read_json parsing.
* This preserves the M coordinate (typically timestamps for trip data) which ST_READ drops.
* The M values are stored in the 4th position of each coordinate array in the _geojson column.
*/
private async importGeoJsonWithXYZM({
data,
db,
c,

Check warning on line 222 in src/duckdb/src/table/duckdb-table.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

'data' is defined but never used. Allowed unused args must match /^_/u
geojsonStr
}: ImportDataToDuckProps & {geojsonStr: string}): Promise<ImportDataToDuckResult> {
try {
await db.registerFileText(this.id, geojsonStr);

await c.query(`install spatial; load spatial;`);

// Step 1: Parse GeoJSON and create initial table with properties and geometry JSON
// We store the geometry as a JSON string to preserve 4D coordinates
const createTableSql = `
CREATE TABLE '${this.label}' AS
Comment thread
lixun910 marked this conversation as resolved.
Outdated
WITH raw_features AS (
SELECT unnest(features) as feature
FROM read_json_auto('${this.id}')
)
SELECT
feature->'properties' as __props__,
-- Store geometry as JSON string to preserve 4D coordinates
(feature->'geometry')::VARCHAR as "${KEPLER_GEOM_FROM_GEOJSON_COLUMN}"
FROM raw_features;
`;

await c.query(createTableSql);

// Step 2: Get property keys from the first row to expand them into columns
const propsResult = await c.query(`
SELECT __props__ FROM '${this.label}' WHERE __props__ IS NOT NULL LIMIT 1
`);

if (propsResult.numRows > 0) {
const propsJson = propsResult.getChildAt(0)?.get(0);
if (propsJson) {
let propKeys: string[] = [];
try {
const propsObj = typeof propsJson === 'string' ? JSON.parse(propsJson) : propsJson;
if (propsObj && typeof propsObj === 'object') {
propKeys = Object.keys(propsObj);
}
} catch (e) {
console.warn('[importGeoJsonWithXYZM] Could not parse properties:', e);
}

// Add columns for each property
for (const key of propKeys) {
// Skip if key would conflict with our geometry column
if (key === KEPLER_GEOM_FROM_GEOJSON_COLUMN) continue;

const safeKey = key.replace(/"/g, '""');
try {
await c.query(`
ALTER TABLE '${this.label}'
ADD COLUMN "${safeKey}" VARCHAR;
`);
await c.query(`
UPDATE '${this.label}'
SET "${safeKey}" = json_extract_string(__props__, '$."${safeKey}"');
Comment thread
lixun910 marked this conversation as resolved.
Outdated
`);
} catch (e) {
console.warn(`[importGeoJsonWithXYZM] Could not add property column ${key}:`, e);
}
}
}
}

// Step 3: Drop the intermediate __props__ column
try {
await c.query(`ALTER TABLE '${this.label}' DROP COLUMN __props__;`);
} catch (e) {
console.warn('[importGeoJsonWithXYZM] Could not drop __props__ column:', e);
}

console.log('[importGeoJsonWithXYZM] Successfully imported GeoJSON with XYZM coordinates preserved');
} catch (error) {
console.error('importGeoJsonWithXYZM', error);
throw error;
}

return {
// The _geojson column contains geometry JSON with 4D coordinates
// It will be parsed by parseGeometryFromString which handles JSON strings
geoarrowMetadata: {[KEPLER_GEOM_FROM_GEOJSON_COLUMN]: GEOARROW_EXTENSIONS.WKB}
Comment thread
lixun910 marked this conversation as resolved.
};
}
Expand Down
6 changes: 4 additions & 2 deletions src/layers/src/geojson-layer/geojson-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ export function groupColumnsAsGeoJson(
for (let index = 0; index < dataContainer.numRows(); index++) {
// note: can materialize the row
const datum = dataContainer.rowAsArray(index);
const id = datum[columns.id.fieldIdx];
// Convert id to string to ensure consistent object key behavior
// (numeric keys are sorted differently than string keys in Object.entries)
const id = `${datum[columns.id.fieldIdx]}`;
Comment thread
lixun910 marked this conversation as resolved.
Outdated
const lat = datum[columns.lat.fieldIdx];
const lon = datum[columns.lng.fieldIdx];
const altitude = columns.altitude ? datum[columns.altitude.fieldIdx] : 0;
Expand Down Expand Up @@ -420,7 +422,7 @@ export function detectTableColumns(
// find sort by field
const sortByFieldIdx = fields.findIndex(f => f.type === ALL_FIELD_TYPES.timestamp);
// find id column
const idFieldIdx = fields.findIndex(f => f.name?.toLowerCase().match(/^(id|uuid)$/g));
const idFieldIdx = fields.findIndex(f => f.name?.toLowerCase().match(/(id|uuid)/g));
Comment thread
lixun910 marked this conversation as resolved.
Outdated

if (sortByFieldIdx > -1 && idFieldIdx > -1) {
const pointColumns = assignPointPairToLayerColumn(fieldPairs[0], true);
Expand Down
37 changes: 35 additions & 2 deletions src/layers/src/trip-layer/trip-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,10 @@ export default class TripLayer extends Layer {
}

static findDefaultLayerProps(
{label, fields = [], dataContainer, id}: KeplerTable,
{label, fields = [], dataContainer, id, fieldPairs = []}: KeplerTable,
foundLayers?: any[]
) {
const geojsonColumns = fields.filter(f => f.type === 'geojson').map(f => f.name);
const geojsonColumns = fields.filter(f => f.type === 'geojson' || f.type === 'geoarrow' || f.type === 'geoarrow-wkb').map(f => f.name);
Comment thread
lixun910 marked this conversation as resolved.
Outdated

const defaultColumns = {
geojson: uniq([...GEOJSON_FIELDS.geojson, ...geojsonColumns])
Expand Down Expand Up @@ -277,6 +277,39 @@ export default class TripLayer extends Layer {
};
}

// Try to detect table columns (id/lat/lng/timestamp) for table column mode
// This allows creating trip layers from tabular data without GeoJSON
if (fieldPairs.length && fields.length) {
// Default layer columns for table mode
const defaultTableColumns = {
id: {value: null, fieldIdx: -1},
lat: {value: null, fieldIdx: -1},
lng: {value: null, fieldIdx: -1},
timestamp: {value: null, fieldIdx: -1},
altitude: {value: null, fieldIdx: -1, optional: true},
geojson: {value: null, fieldIdx: -1}
};

const tableColumns = detectTableColumns(
{fields, fieldPairs} as KeplerTable,
defaultTableColumns,
'timestamp'
);

if (tableColumns) {
// Found required columns for table mode
return {
props: [{
label: tableColumns.label || (typeof label === 'string' && label.replace(/\.[^/.]+$/, '')) || this.type,
columns: tableColumns.columns,
isVisible: true,
columnMode: COLUMN_MODE_TABLE
}],
foundLayers
};
}
}

return {props: []};
}

Expand Down
Loading