Skip to content

Commit ad6bb26

Browse files
committed
Adds ability to join another (results) database
1 parent 165746b commit ad6bb26

6 files changed

Lines changed: 364 additions & 32 deletions

File tree

src/plugins/aequilibrae/AequilibraEReader.vue

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const MyComponent = defineComponent({
9191
aeqFileSystem: null as any,
9292
spl: null as any,
9393
db: null as any,
94+
extraDbs: new Map() as Map<string, any>,
9495
tables: [] as Array<{name: string, type: string, rowCount: number, columns: any[]}>,
9596
searchTerm: '',
9697
isLoaded: false,
@@ -156,13 +157,29 @@ const MyComponent = defineComponent({
156157
this.loadingText = 'Loading SQL engine with spatialite...'
157158
this.spl = await initSql()
158159
159-
// Load the database
160+
// Load the main database
160161
this.loadingText = 'Loading database...'
161162
const dbPath = this.vizDetails.database
162163
const blob = await this.aeqFileSystem.getFileBlob(dbPath)
163164
const arrayBuffer = await blob.arrayBuffer()
164165
this.db = await openDb(this.spl, arrayBuffer)
165166
167+
// Load extra databases for joins
168+
if (this.vizDetails.extraDatabases) {
169+
this.loadingText = 'Loading extra databases for joins...'
170+
for (const [name, path] of Object.entries(this.vizDetails.extraDatabases)) {
171+
try {
172+
const extraBlob = await this.aeqFileSystem.getFileBlob(path)
173+
const extraArrayBuffer = await extraBlob.arrayBuffer()
174+
const extraDb = await openDb(this.spl, extraArrayBuffer)
175+
this.extraDbs.set(name, extraDb)
176+
console.log(`✅ Loaded extra database '${name}' from ${path}`)
177+
} catch (e) {
178+
console.warn(`⚠️ Failed to load extra database '${name}' from ${path}:`, e)
179+
}
180+
}
181+
}
182+
166183
// Get table information
167184
this.loadingText = 'Reading tables...'
168185
const { tables, hasGeometry } = await buildTables(this.db, this.layerConfigs)
@@ -171,7 +188,8 @@ const MyComponent = defineComponent({
171188
172189
if (this.hasGeometry) {
173190
this.loadingText = 'Extracting geometries...'
174-
const features = await buildGeoFeatures(this.db, this.tables, this.layerConfigs)
191+
// Pass extra databases for join processing
192+
const features = await buildGeoFeatures(this.db, this.tables, this.layerConfigs, this.extraDbs)
175193
this.geoJsonFeatures = features.filter((f: any) => f && f.geometry && f.properties)
176194
177195
const styles = buildStyleArrays({
@@ -227,22 +245,35 @@ const MyComponent = defineComponent({
227245
// Capture view preference from config
228246
if (this.config.view) this.vizDetails.view = this.config.view
229247
230-
// Populate layer configurations for rendering
248+
// Process extraDatabases paths
249+
if (this.config.extraDatabases) {
250+
const extraDatabases: Record<string, string> = {}
251+
for (const [name, path] of Object.entries(this.config.extraDatabases)) {
252+
const pathStr = path as string
253+
extraDatabases[name] = pathStr.startsWith('/')
254+
? pathStr
255+
: `${this.subfolder}/${pathStr}`
256+
}
257+
this.vizDetails.extraDatabases = extraDatabases
258+
}
259+
260+
// Populate layer configurations for rendering (includes join configs)
231261
this.layerConfigs = this.config.layers || {}
232-
// Example layer styling:
262+
// Example layer styling with joins:
233263
// layers:
234264
// links:
235265
// table: links
236266
// geometry: geom
267+
// join:
268+
// database: results # Key from extraDatabases
269+
// table: car_skims_results
270+
// leftKey: link_id # Column in links table
271+
// rightKey: link_id # Column in results table
272+
// type: left # left or inner
237273
// style:
238274
// fillColor: { column: 'link_type', scheme: 'Category10' }
239-
// lineColor: { column: 'status', scheme: 'Set2' }
275+
// lineColor: { column: 'travel_time_ratio', scheme: 'RdYlGn' }
240276
// lineWidth: { column: 'lanes', range: [1, 6] }
241-
// pointRadius: { column: 'traffic', range: [2, 12] }
242-
// fillHeight: { column: 'elevation', range: [0, 100] }
243-
// filter: { column: 'status', include: ['open'] }
244-
// fillHeight: { column: 'elevation', range: [0, 100] }
245-
// ...existing code...
246277
} else if (this.yamlConfig) {
247278
// Need to load and parse the YAML file first
248279
const yamlPath = this.subfolder ? `${this.subfolder}/${this.yamlConfig}` : this.yamlConfig
@@ -253,7 +284,6 @@ const MyComponent = defineComponent({
253284
this.vizDetails = parsed
254285
this.layerConfigs = parsed.layers || {}
255286
// same styling shape as above is supported
256-
// ...existing code...
257287
} else {
258288
throw new Error('No config or yamlConfig provided')
259289
}

src/plugins/aequilibrae/db.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { JoinConfig } from './types';
2+
13
export async function getTableNames(db: any): Promise<string[]> {
24
const result = await db.exec("SELECT name FROM sqlite_master WHERE type='table';").get.objs;
35
return result.map((row: any) => row.name);
@@ -17,7 +19,93 @@ export async function getRowCount(db: any, tableName: string): Promise<number> {
1719
return result.length > 0 ? result[0].count : 0;
1820
}
1921

20-
export async function fetchGeoJSONFeatures(db: any, table: { name: string; columns: any[] }, layerName: string, layerConfig: any) {
22+
/**
23+
* Query a table and return all rows as objects
24+
*/
25+
export async function queryTable(db: any, tableName: string, columns?: string[]): Promise<Record<string, any>[]> {
26+
const columnList = columns ? columns.map(c => `"${c}"`).join(', ') : '*';
27+
const query = `SELECT ${columnList} FROM "${tableName}";`;
28+
const result = await db.exec(query).get.objs;
29+
return result;
30+
}
31+
32+
/**
33+
* Perform an in-memory join between main data and external data
34+
* @param mainData - Array of records from the main table
35+
* @param joinData - Array of records from the join table
36+
* @param joinConfig - Configuration specifying join keys and type
37+
* @returns Merged array with joined columns added to main records
38+
*/
39+
export function performJoin(
40+
mainData: Record<string, any>[],
41+
joinData: Record<string, any>[],
42+
joinConfig: JoinConfig
43+
): Record<string, any>[] {
44+
// Build a lookup map for efficient joining using the right key
45+
const joinLookup = new Map<any, Record<string, any>>();
46+
for (const row of joinData) {
47+
const key = row[joinConfig.rightKey];
48+
if (key !== undefined && key !== null) {
49+
joinLookup.set(key, row);
50+
}
51+
}
52+
53+
const joinType = joinConfig.type || 'left';
54+
const results: Record<string, any>[] = [];
55+
56+
for (const mainRow of mainData) {
57+
const joinKey = mainRow[joinConfig.leftKey];
58+
const joinRow = joinLookup.get(joinKey);
59+
60+
if (joinRow) {
61+
// Filter columns if specified
62+
let joinedColumns: Record<string, any>;
63+
if (joinConfig.columns && joinConfig.columns.length > 0) {
64+
joinedColumns = {};
65+
for (const col of joinConfig.columns) {
66+
if (col in joinRow) {
67+
joinedColumns[col] = joinRow[col];
68+
}
69+
}
70+
} else {
71+
// Include all columns from join table (except the join key to avoid duplicates)
72+
joinedColumns = { ...joinRow };
73+
// Optionally remove duplicate key if it has the same name
74+
// We keep it for now as it might have different values
75+
}
76+
77+
// Merge: main row properties take precedence, then add joined columns
78+
results.push({
79+
...mainRow,
80+
...joinedColumns,
81+
});
82+
} else if (joinType === 'left') {
83+
// No match found, but left join keeps the main row
84+
results.push({ ...mainRow });
85+
}
86+
// For 'inner' join, skip rows with no match
87+
}
88+
89+
return results;
90+
}
91+
92+
/**
93+
* Fetch GeoJSON features from a table, optionally merging joined data
94+
* @param db - The main database connection
95+
* @param table - Table metadata with name and columns
96+
* @param layerName - Name of the layer for feature properties
97+
* @param layerConfig - Layer configuration
98+
* @param joinedData - Optional pre-joined data to merge into features (keyed by join column)
99+
* @param joinConfig - Optional join configuration specifying the key column
100+
*/
101+
export async function fetchGeoJSONFeatures(
102+
db: any,
103+
table: { name: string; columns: any[] },
104+
layerName: string,
105+
layerConfig: any,
106+
joinedData?: Map<any, Record<string, any>>,
107+
joinConfig?: JoinConfig
108+
) {
21109
const columnNames = table.columns
22110
.filter((c: any) => c.name.toLowerCase() !== 'geometry')
23111
.map((c: any) => `"${c.name}"`)
@@ -33,15 +121,46 @@ export async function fetchGeoJSONFeatures(db: any, table: { name: string; colum
33121
`;
34122
const rows = await db.exec(query).get.objs;
35123
const features: any[] = [];
124+
125+
const joinType = joinConfig?.type || 'left';
126+
36127
for (const row of rows) {
37128
if (!row.geojson_geom) continue;
129+
130+
// Build base properties from main table
38131
const properties: any = { _table: table.name, _layer: layerName, _layerConfig: layerConfig };
39132
for (const col of table.columns) {
40133
const key = col.name;
41134
if (key.toLowerCase() !== 'geometry' && key !== 'geojson_geom' && key !== 'geom_type') {
42135
properties[key] = row[key];
43136
}
44137
}
138+
139+
// Merge joined data if available
140+
if (joinedData && joinConfig) {
141+
const joinKey = row[joinConfig.leftKey];
142+
const joinRow = joinedData.get(joinKey);
143+
144+
if (joinRow) {
145+
// Add joined columns to properties
146+
for (const [key, value] of Object.entries(joinRow)) {
147+
// Don't overwrite existing properties (main table takes precedence)
148+
if (!(key in properties)) {
149+
properties[key] = value;
150+
} else if (key !== joinConfig.rightKey) {
151+
// If column name conflicts, prefix with table name
152+
properties[`${joinConfig.table}_${key}`] = value;
153+
}
154+
}
155+
properties._hasJoinedData = true;
156+
} else if (joinType === 'inner') {
157+
// Skip this feature for inner join when no match
158+
continue;
159+
} else {
160+
properties._hasJoinedData = false;
161+
}
162+
}
163+
45164
features.push({ type: 'Feature', geometry: row.geojson_geom, properties });
46165
}
47166
return features;

src/plugins/aequilibrae/example-config.yaml

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ description: "Visualize network nodes, links, and zones from AequilibraE"
77
# Path to the AequilibraE database file (relative to this YAML file)
88
database: project_database.sqlite
99

10+
# Optional: Additional databases for joining results data
11+
# Keys are reference names used in layer join configurations
12+
extraDatabases:
13+
results: results_database.sqlite
14+
# You can add multiple: another_db: another_results.sqlite
15+
1016
# Define which database tables/layers to render and their styling
1117
layers:
1218
zones:
@@ -21,10 +27,30 @@ layers:
2127
links:
2228
table: links
2329
type: line
24-
strokeColor: "#FF6600" # Bright orange
30+
31+
# Join simulation results from an external database
32+
join:
33+
database: results # Reference to extraDatabases key
34+
table: car_skims_results # Table name in the results database
35+
leftKey: link_id # Column in links table
36+
rightKey: link_id # Column in results table
37+
type: left # left = keep all links, inner = only matched
38+
# columns: [travel_time_ratio, volume] # Optional: specific columns only
39+
40+
# Style using columns from both main and joined tables
41+
style:
42+
lineColor:
43+
column: travel_time_ratio # Column from joined results
44+
scheme: RdYlGn # Red-Yellow-Green diverging scale
45+
# reversed: true # Optional: reverse the color scale
46+
lineWidth:
47+
column: lanes_ab # Column from main links table
48+
range: [1, 6]
49+
50+
strokeColor: "#FF6600" # Fallback color if no style defined
2551
strokeWidth: 3
2652
opacity: 0.9
27-
zIndex: 2 # Render on top
53+
zIndex: 2
2854

2955
nodes:
3056
table: nodes

0 commit comments

Comments
 (0)