diff --git a/index.html b/index.html
index a20467e4..5b3c4a49 100644
--- a/index.html
+++ b/index.html
@@ -27,7 +27,7 @@
-
SimWrapper
+ AequilibraE Explore
diff --git a/package.json b/package.json
index 13133efb..ff65102f 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"blob-util": "^2.0.2",
"buefy": "^0.9.29",
"bulma": "^1.0.0",
+ "cartocolor": "^5.0.2",
"color-string": "^1.9.1",
"colormap": "^2.3.1",
"comlink": "^4.4.2",
@@ -119,6 +120,10 @@
"sax-wasm": "^2.2.4",
"shallow-equal": "^1.2.1",
"shapefile": "^0.6.6",
+ "spatialite": "^0.1.0",
+ "spl.js": "^0.1.2",
+ "sql.js": "^1.13.0",
+
"the-new-css-reset": "^1.7.3",
"threads": "^1.7.0",
"three": "^0.127.0",
diff --git a/src/Globals.ts b/src/Globals.ts
index dc9f52cf..8aee7f37 100644
--- a/src/Globals.ts
+++ b/src/Globals.ts
@@ -168,6 +168,7 @@ export interface FileSystemConfig {
example?: boolean
isGithub?: boolean
isZIB?: boolean
+ isS3?: boolean // AWS S3 bucket with public access
flask?: boolean // Flask filesystem supports OMX open matrix API - see https://github.com/simwrapper/omx-server
}
diff --git a/src/components/LegendColors.vue b/src/components/LegendColors.vue
index 6f9cfc46..e733b4c2 100644
--- a/src/components/LegendColors.vue
+++ b/src/components/LegendColors.vue
@@ -1,11 +1,24 @@
+
.legend-colors.flex-col
- h4 {{ title }}
- p {{ description }}
+ h4(v-if="title") {{ title }}
+ p(v-if="description") {{ description }}
ul.list-items
- li.legend-row(v-for="item in items" :key="item.value + item.value[0]")
- .item-label(v-if="item.label") {{ item.label }}
- .item-swatch(:style="`backgroundColor: rgb(${item.color})`")
+ li.legend-row(v-for="(item, idx) in items" :key="item.label + idx")
+ // Subtitle
+ span.legend-subtitle(v-if="item.type === 'subtitle'") {{ item.label }}
+ // Feature entry
+ template(v-else)
+ .item-swatch(v-if="item.shape === 'line' || item.type === 'line'"
+ :style="`width:32px;height:${item.size||4}px;background:rgb(${item.color});border-radius:2px;align-self:center;`"
+ )
+ .item-swatch(v-else-if="item.shape === 'polygon' || item.type === 'polygon'"
+ :style="`width:20px;height:20px;background:rgb(${item.color});border-radius:4px;border:1px solid #888;display:inline-block;`"
+ )
+ .item-swatch(v-else-if="item.shape === 'circle' || item.type === 'circle'"
+ :style="`width:${item.size||12}px;height:${item.size||12}px;background:rgb(${item.color});border-radius:50%;border:1px solid #888;display:inline-block;`"
+ )
+ .item-label {{ item.label }}
@@ -32,6 +45,13 @@ export default defineComponent({
margin: 0;
}
+.legend-subtitle {
+ font-weight: bold;
+ margin-top: 0.5em;
+ margin-bottom: 0.25em;
+ display: block;
+}
+
.item-label {
margin: '0 0.5rem 0.0rem 0';
font-weight: 'bold';
diff --git a/src/dash-panels/_allPanels.ts b/src/dash-panels/_allPanels.ts
index 0573d107..74391de7 100644
--- a/src/dash-panels/_allPanels.ts
+++ b/src/dash-panels/_allPanels.ts
@@ -28,6 +28,7 @@ export const panelLookup: { [key: string]: AsyncComponent } = {
xml: defineAsyncComponent(() => import('./xml.vue')),
// full-screen map visualizations:
+ aequilibrae: defineAsyncComponent(() => import('./aequilibrae.vue')),
carriers: defineAsyncComponent(() => import('./carriers.vue')),
flowmap: defineAsyncComponent(() => import('./flowmap.vue')),
links: defineAsyncComponent(() => import('./links.vue')),
diff --git a/src/dash-panels/aequilibrae.vue b/src/dash-panels/aequilibrae.vue
new file mode 100644
index 00000000..be8054bf
--- /dev/null
+++ b/src/dash-panels/aequilibrae.vue
@@ -0,0 +1,49 @@
+
+aeq-reader.aeq-panel(
+ :root="fileSystemConfig.slug"
+ :subfolder="subfolder"
+ :config="config"
+ :thumbnail="false"
+ @isLoaded="isLoaded"
+ @error="$emit('error', $event)"
+)
+
+
+
+
+
+
diff --git a/src/dash-panels/tile.vue b/src/dash-panels/tile.vue
index e749104f..ed218b01 100644
--- a/src/dash-panels/tile.vue
+++ b/src/dash-panels/tile.vue
@@ -1,10 +1,14 @@
.content
.tiles-container(v-if="imagesAreLoaded")
- .tile(v-for="(value, index) in this.dataSet.data" v-bind:style="{ 'background-color': colors[index % colors.length]}" @click="")
+ .tile(
+ v-for="(value, index) in this.dataSet.data"
+ :style="getTileStyle(index)"
+ @click=""
+ )
a(:href="value[urlIndex]" target="_blank" :class="{ 'is-not-clickable': !value[urlIndex] }")
- p.tile-title {{ value[tileNameIndex] }}
- p.tile-value {{ value[tileValueIndex] }}
+ p.tile-title(:style="{ color: tileTextColor }") {{ value[tileNameIndex] }}
+ p.tile-value(:style="{ color: tileTextColor }") {{ value[tileValueIndex] }}
.tile-image(v-if="value[tileImageIndex] != undefined && checkIfItIsACustomIcon(value[tileImageIndex])" :style="{'background': base64Images[index], 'background-size': 'contain'}")
img.tile-image(v-else-if="value[tileImageIndex] != undefined && checkIfIconIsInAssetsFolder(value[tileImageIndex])" v-bind:src="getLocalImage(value[tileImageIndex].trim())" :style="{'background': ''}")
font-awesome-icon.tile-image(v-else-if="value[tileImageIndex] != undefined" :icon="value[tileImageIndex].trim()" size="2xl" :style="{'background': '', 'color': 'black'}")
@@ -45,6 +49,10 @@ export default defineComponent({
dataSet: {} as { data?: any; x?: any[]; y?: any[]; allRows?: any },
YAMLrequirementsOverview: { dataset: '' },
colors: [
+ '#dddddd00', // light gray
+ '#dddddd00', // light gray
+ '#dddddd00', // light gray
+ '#dddddd00', // light gray
'#F08080', // Light coral pink
'#FFB6C1', // Pale pink
'#FFDAB9', // peach
@@ -117,6 +125,12 @@ export default defineComponent({
fileApi(): HTTPFileSystem {
return new HTTPFileSystem(this.fileSystemConfig, globalStore)
},
+ tileBorderColor(): string {
+ return this.globalState.isDarkMode ? '#fff' : '#000';
+ },
+ tileTextColor(): string {
+ return this.globalState.isDarkMode ? '#fff' : '#363636';
+ },
},
async mounted() {
this.dataSet = await this.loadFile()
@@ -227,6 +241,14 @@ export default defineComponent({
}
return false
},
+
+ getTileStyle(index: number) {
+ return {
+ 'background-color': this.colors[index % this.colors.length],
+ 'border': '1px solid ' + this.tileBorderColor,
+ 'color': this.tileTextColor
+ }
+ },
},
})
@@ -277,13 +299,13 @@ export default defineComponent({
padding: 20px;
min-width: 250px;
font-family: $fancyFont;
+ border-color: v-bind(tileBorderColor);
}
.tile .tile-value {
- font-size: 2rem;
+ font-size: 3rem;
font-weight: bold;
width: 100%;
- color: #363636; // var(--text) but always the color from the light mode.
grid-column-start: 2;
grid-column-end: 4;
text-align: center;
@@ -292,10 +314,9 @@ export default defineComponent({
.tile .tile-title {
width: 100%;
- font-size: 1.4rem;
- height: 5rem;
+ font-size: 2.5rem;
+ // height: 3.5rem;
margin-bottom: 0;
- color: #363636; // var(--text) but always the color from the light mode.
text-align: center;
grid-column-start: 1;
grid-column-end: 5;
@@ -314,4 +335,4 @@ export default defineComponent({
@media only screen and (max-width: 640px) {
}
-
+
\ No newline at end of file
diff --git a/src/fileSystemConfig.ts b/src/fileSystemConfig.ts
index 5b25051d..6e109b4f 100644
--- a/src/fileSystemConfig.ts
+++ b/src/fileSystemConfig.ts
@@ -250,4 +250,4 @@ try {
console.error('ERROR MERGING URL SHORTCUTS:', '' + e)
}
-export default fileSystems
+export default fileSystems
\ No newline at end of file
diff --git a/src/js/HTTPFileSystem.ts b/src/js/HTTPFileSystem.ts
index ecf7bbf2..e5cbbcdd 100644
--- a/src/js/HTTPFileSystem.ts
+++ b/src/js/HTTPFileSystem.ts
@@ -1,5 +1,6 @@
import micromatch from 'micromatch'
import naturalSort from 'javascript-natural-sort'
+import { SaxEventType, SAXParser } from 'sax-wasm'
import { gUnzip } from '@/js/util'
@@ -17,6 +18,7 @@ enum FileSystemType {
GITHUB,
FLASK,
LAKEFS,
+ S3,
}
naturalSort.insensitive = true
@@ -42,6 +44,7 @@ class HTTPFileSystem {
private isGithub: boolean
private isZIB: boolean
private isFlask: boolean
+ private isS3: boolean
private type: FileSystemType
private fileLinkLookup: any = {}
@@ -54,12 +57,14 @@ class HTTPFileSystem {
this.isGithub = !!project.isGithub
this.isFlask = !!project.flask
this.isZIB = !!project.isZIB
+ this.isS3 = !!project.isS3
this.type = FileSystemType.FETCH
if (this.fsHandle) this.type = FileSystemType.CHROME
if (this.isGithub) this.type = FileSystemType.GITHUB
if (this.isFlask) this.type = FileSystemType.FLASK
if (this.isZIB) this.type = FileSystemType.LAKEFS
+ if (this.isS3) this.type = FileSystemType.S3
this.baseUrl = project.baseURL
if (!project.baseURL.endsWith('/')) this.baseUrl += '/'
@@ -120,6 +125,9 @@ class HTTPFileSystem {
return this._getFileFromLakeFS(scaryPath)
case FileSystemType.FLASK:
return this._getFileFromAzure(scaryPath)
+ case FileSystemType.S3:
+ // S3 buckets use standard HTTP GET for files
+ return this._getFileFetchResponse(scaryPath)
case FileSystemType.FETCH:
default:
return this._getFileFetchResponse(scaryPath)
@@ -484,6 +492,12 @@ class HTTPFileSystem {
.then(response => response.blob())
.then(blob => blob.stream())
return stream as any
+ case FileSystemType.S3:
+ // S3 buckets use standard HTTP GET for files
+ stream = await this._getFileFetchResponse(scaryPath, options).then(
+ response => response.body
+ )
+ return stream as any
case FileSystemType.FETCH:
stream = await this._getFileFetchResponse(scaryPath, options).then(
response => response.body
@@ -533,6 +547,9 @@ class HTTPFileSystem {
case FileSystemType.FLASK:
dirEntry = await this._getDirectoryFromAzure(stillScaryPath)
break
+ case FileSystemType.S3:
+ dirEntry = await this._getDirectoryFromS3(stillScaryPath)
+ break
case FileSystemType.LAKEFS:
case FileSystemType.FETCH:
default:
@@ -617,6 +634,99 @@ class HTTPFileSystem {
return contents
}
+ async _getDirectoryFromS3(stillScaryPath: string): Promise {
+ // S3 uses a list API with prefix and delimiter to simulate directories
+ // https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
+
+ let prefix = stillScaryPath.replace(/^\/+/, '') // remove leading slashes
+ prefix = prefix.replaceAll('//', '/')
+
+ // Build the S3 list URL with query parameters
+ const listUrl = `${this.baseUrl}?list-type=2&delimiter=/&prefix=${encodeURIComponent(prefix)}`
+
+ const response = await fetch(listUrl)
+ if (response.status !== 200) {
+ console.warn('S3 list status:', response.status)
+ throw response
+ }
+
+ const xmlText = await response.text()
+ return await this.buildListFromS3Xml(xmlText, prefix)
+ }
+
+ private async buildListFromS3Xml(xmlText: string, prefix: string): Promise {
+ const dirs: string[] = []
+ const files: string[] = []
+
+ try {
+ const parser = new SAXParser(SaxEventType.OpenTag | SaxEventType.Text | SaxEventType.CloseTag, { highWaterMark: 32 * 1024 })
+ await parser.prepareWasm()
+
+ let path = '', inKey = false, inPrefix = false
+
+ parser.eventHandler = (event, data) => {
+ const tag = data.name
+ if (event === SaxEventType.OpenTag) {
+ if (tag === 'Key') inKey = true
+ else if (tag === 'Prefix') inPrefix = true
+ } else if (event === SaxEventType.Text && (inKey || inPrefix)) {
+ path += data.value
+ } else if (event === SaxEventType.CloseTag) {
+ if (tag === 'Key') {
+ inKey = false
+ if (path && path.startsWith(prefix)) {
+ const key = path.substring(prefix.length)
+ if (key && !key.endsWith('/')) files.push(key)
+ }
+ path = ''
+ } else if (tag === 'Prefix') {
+ inPrefix = false
+ if (path && path.startsWith(prefix)) {
+ const dir = path.substring(prefix.length).replace(/\/$/, '')
+ if (dir) dirs.push(dir)
+ }
+ path = ''
+ }
+ }
+ }
+
+ parser.write(new TextEncoder().encode(xmlText))
+ parser.end()
+
+ } catch (error) {
+ console.warn('SAX parsing failed, falling back to regex:', error)
+
+ // Fallback to regex parsing if sax-wasm fails
+ const contentsRegex = /[\s\S]*?(.*?)<\/Key>[\s\S]*?<\/Contents>/g
+ let match
+ while ((match = contentsRegex.exec(xmlText)) !== null) {
+ let key = match[1]
+ if (key.startsWith(prefix)) {
+ key = key.substring(prefix.length)
+ }
+ if (key && key !== '' && !key.endsWith('/')) {
+ files.push(key)
+ }
+ }
+
+ const prefixRegex = /[\s\S]*?(.*?)<\/Prefix>[\s\S]*?<\/CommonPrefixes>/g
+ while ((match = prefixRegex.exec(xmlText)) !== null) {
+ let dirPath = match[1]
+ if (dirPath.startsWith(prefix)) {
+ dirPath = dirPath.substring(prefix.length)
+ }
+ if (dirPath.endsWith('/')) {
+ dirPath = dirPath.slice(0, -1)
+ }
+ if (dirPath && dirPath !== '') {
+ dirs.push(dirPath)
+ }
+ }
+ }
+
+ return { dirs, files, handles: {} }
+ }
+
async _getDirectoryFromURL(stillScaryPath: string) {
const response = await this._getFileResponse(stillScaryPath)
// console.log(response)
diff --git a/src/js/util-worker.ts b/src/js/util-worker.ts
new file mode 100644
index 00000000..866d1e62
--- /dev/null
+++ b/src/js/util-worker.ts
@@ -0,0 +1,65 @@
+/**
+ * Worker-safe utilities
+ * These functions can be safely imported in Web Workers
+ * (no DOM dependencies like DOMParser, FileReader, etc.)
+ */
+
+import micromatch from 'micromatch'
+
+export function findMatchingGlobInFiles(filenames: string[], glob: string): string[] {
+ // first see if file itself is in this folder
+ if (filenames.indexOf(glob) > -1) return [glob]
+
+ // return globs in this folder
+ const matches = micromatch(filenames, glob)
+ if (matches.length) return matches
+
+ // nothing!
+ return []
+}
+
+export async function gUnzip(buffer: ArrayBuffer) {
+ // GZIP always starts with a magic number, hex 0x8b1f
+ const header = new Uint16Array(buffer, 0, 2)
+ if (header[0] === 0x8b1f) {
+ try {
+ // use new 2023 DecompressionStream API
+ const response = new Response(buffer)
+ const stream = new DecompressionStream('gzip')
+ const decompressed = await response.body?.pipeThrough(stream)
+ const resultBuffer = await new Response(decompressed).arrayBuffer()
+ // recursive because some combos of Firefox,Apache,Subversion will double-gzip!
+ return await gUnzip(resultBuffer)
+ } catch (e) {
+ console.error('eee', e)
+ }
+ }
+ return buffer
+}
+
+/**
+ * Concat multiple typed arrays into one.
+ * @param arrays a list of typed arrays
+ * @returns
+ */
+export function mergeTypedArrays(arrays: Array[]): Array {
+ if (arrays.length == 0) return new Array()
+ if (arrays.length == 1) return arrays[0]
+
+ const total = arrays.map(a => a.length).reduce((t, n) => t + n)
+
+ const c = Object.getPrototypeOf(arrays[0]).constructor
+ const result = new c(total)
+
+ let n = 0
+ for (const arr of arrays) {
+ result.set(arr, n)
+ n += arr.length
+ }
+
+ return result
+}
+
+export function sleep(milliseconds: number) {
+ return new Promise(resolve => setTimeout(resolve, milliseconds))
+}
diff --git a/src/layout-manager/SplashPage.vue b/src/layout-manager/SplashPage.vue
index d53a9c5e..4537f957 100644
--- a/src/layout-manager/SplashPage.vue
+++ b/src/layout-manager/SplashPage.vue
@@ -12,26 +12,6 @@
.markdown(v-if="readme" v-html="readme")
- //- QUICK START ==================
- h4.az-title(style="margin-top: 1rem;") Quick start tools
- .az-quick-start-items.flex-row
- .az-quick-item.flex-col(v-if="isChrome" @click="showChromeDirectory")
- .az-quick-icon: i.fa.fa-folder
- .az-quick-label View
local files
- .az-quick-item.flex-col(@click="go('/matrix')")
- .az-quick-icon: i.fa.fa-th
- .az-quick-label Matrix
viewer
- //- .az-quick-item.flex-col(@click="go('/map')")
- //- . az-quick-icon: i.fa.fa-plus
- //- .az-quick-label Map builder
(beta)
- .az-quick-item.flex1.spacer
-
- settings-panel.settings-popup(v-if="showSettings" @close="showSettings=false")
- .az-quick-item.flex-col(v-else @click="showSettings = !showSettings")
- .az-quick-icon: i.fa.fa-cog
- .az-quick-label Settings
-
-
//- LOCAL FOLDERS ==================
.is-chrome(v-if="isChrome")
h4.az-title Local folders
@@ -53,60 +33,6 @@
@click="showChromeDirectory"
): b View local files...
-
- //- DATA SOURCES ==================
- h4.az-title Data sources
- .az-grid
- //- .az-cell
- .az-cell.heading Resource
- .az-cell.heading Description
- .az-row(v-for="project in mainRoots" :key="project.slug")
- //- .az-cell(style="padding-right: 0.5rem; font-size: 12px;"): i.fa.fa-network-wired
- .az-cell.has-link
- i.fa.fa-sitemap.az-icon(style="color: #99cc00")
- a(@click="clickedOnFolder({root: project.slug})") {{ project.name}}
- .az-cell {{ project.description}}
- p
- | Add more cloud data sources from the
- a(@click="openDataStrip()"): b data sources
- | tab on the left-side panel.
-
-
- //- FAVORITES =========================
- h4.az-title Favorites ⭐️
-
- p(v-if="!state.favoriteLocations.length") Click the star ⭐️ in the top right of any view or dashboard to add it to this list.
-
- .az-grid(v-else)
- .az-cell.heading Item
- .az-cell.heading Location
- .az-row(v-for="favorite in state.favoriteLocations" :key="favorite.fullPath"
- @click="clickedOnFavorite(favorite)"
- )
- .az-cell
- i.fa.fa-folder(style="padding-right: 0.5rem; font-size: 14px; color: #ea0;")
- a(@click="clickedOnFavorite(favorite)") {{ favorite.label }}
- .az-cell {{ `${favorite.root}/${favorite.subfolder}` }}
-
-
- //- DOCUMENTATION ==================
- h4.az-title Documentation and Help
-
- .flex-row.az-quick-start-items
- a.az-quick-item.flex-col(href="https://simwrapper.github.io/docs" target="_blank")
- .az-quick-icon: i.fa.fa-book
- .az-quick-label Main
docs
- a.az-quick-item.flex-col(href="https://simwrapper.github.io/docs/guide-getting-started " target="_blank")
- .az-quick-icon: i.fa.fa-flag-checkered
- .az-quick-label Tutorial
- a.az-quick-item.flex-col(href="https://github.com/orgs/simwrapper/discussions" target="_blank")
- .az-quick-icon: i.fa.fa-comments
- .az-quick-label Ask
questions
- a.az-quick-item.flex-col(href="https://github.com/simwrapper/simwrapper/issues" target="_blank")
- .az-quick-icon: i.fa.fa-spider
- .az-quick-label Report
an issue
-
-
//- EXAMPLE DASHBOARDS ==================
h4.az-title Example dashboards
p.mb1 Explore these example dashboards to get a feeling for what SimWrapper can do:
@@ -125,74 +51,12 @@
.newbie-area.white-text
.content
- img.screenshot(:src="images.berlin")
-
- p
- | SimWrapper is a unique, web-based data visualization tool for researchers building disaggregate transportation simulations with software such as
- a(href="https://matsim.org") MATSim
- | and
- a(href="https://activitysim.github.io") ActivitySim.
-
- p Explore simulation results directly, or create interactive project dashboards with Simwrapper. It provides many statistical views and chart types, just like other visualization frameworks. But SimWrapper also knows a lot about transportation, and has good defaults for producing visualizations of network link volumes, agent movements through time, aggregate area maps, scenario comparison, and a lot more.
-
- p You don't need to be a coder to use SimWrapper -- you point it at your files and write some small text configuration files to tell SimWrapper what to do. SimWrapper does the rest!
-
- p The open-source code and plugin architecture of SimWrapper allows developers (you!) to fork the project and create your own visualizations, too. But you don't need to be a software developer to use SimWrapper if it already does what you need.
-
- p
- | SimWrapper is a
- b 100% client-side
- | browser application. There is no back-end database, no tracking cookies, and no data is transferred from your browser to any server; everything on your computer stays on your local computer.
-
-
- //- SPONSORS -------------------------------------------------------------------
-
- .sponsors-area.dark-text
- .content
-
- b.section-head.zcaps Funding partners
-
- .links-and-logos
- .logos
- .one-logo(v-for="logo in allLogos" :key="logo.url")
- a(:href="logo.url"
- :title="logo.name"
- target="_blank"
- ): img.img-logo(:src="logo.image")
-
- p Funded by TU Berlin; the German Bundesministerium für Bildung und Forschung; the Deutsche Forschungsgemeinschaft; and the ActivitySim Consortium member agencies listed above. Thank you for your support!
-
-
- //- FOOTER -------------------------------------------------------------------
-
- .diagonal
- .footer-area.white-text
- .content
-
- .flex-col
- .badges
- a(href='https://vsp.berlin/' target="_blank"): img.vsp-logo(src="@/assets/vsp-logo/vsp-2023-logo.png")
-
- .legal.flex1
- h4.section-head SimWrapper
- p © 2025 Technische Universität Berlin
-
- h4 Build information
- p Version:
- b {{ git.tag }}
- p Built from commit:
- b {{ git.commit }}
- p SimWrapper is open source and available on
- a(href="https://github.com/simwrapper/simwrapper") GitHub.
- .flex-row(style="gap: 1rem; margin-top: 1rem;")
- a(href="https://vsp.berlin/en/" target="_blank") VSP TU Berlin
- a(href="https://vsp.berlin/impressum/" target="_blank") Impressum
- a(href="https://www.vsp.tu-berlin.de/menue/service/privacy/parameter/en/" target="_blank") Privacy
-
-
- .very-bottom
- p . .
-
+ p SimWrapper is a visualisation tool, primarily for transport simulation model outputs. Check out the work by the original developers at
+ a(href="https://vsp.tu-berlin.de/en/" target="_blank" rel="noopener") SimWrapper Github repository
+ | or visit
+ a(href="https://simwrapper.app" target="_blank" rel="noopener") the site
+ | to try it out online.
+
+
+
diff --git a/src/plugins/aequilibrae/useAequilibrae.ts b/src/plugins/aequilibrae/useAequilibrae.ts
new file mode 100644
index 00000000..09c79c57
--- /dev/null
+++ b/src/plugins/aequilibrae/useAequilibrae.ts
@@ -0,0 +1,54 @@
+// Core utilities for AequilibraE plugin: YAML parsing and configuration handling
+
+import YAML from 'yaml'
+import type { VizDetails, LayerConfig } from '../sqlite-map/types'
+import { resolvePath, resolvePaths } from '../sqlite-map/utils'
+
+// Keep parseYamlConfig here as a convenience; feature builders moved to sqlite-map/feature-builder
+export async function parseYamlConfig(
+ yamlText: string,
+ subfolder: string | null
+): Promise {
+ const config = YAML.parse(yamlText)
+ const dbFile = config.database || config.file
+ if (!dbFile) throw new Error('No database field found in YAML config')
+
+ const databasePath = resolvePath(dbFile, subfolder)
+
+ // Process extraDatabases paths
+ let extraDatabases: Record | undefined
+ if (config.extraDatabases) {
+ extraDatabases = resolvePaths(config.extraDatabases, subfolder)
+ }
+
+ return {
+ title: config.title || dbFile,
+ description: config.description || '',
+ database: databasePath,
+ extraDatabases,
+ view: config.view || '',
+ layers: config.layers || {},
+ center: config.center,
+ zoom: config.zoom,
+ bearing: config.bearing,
+ pitch: config.pitch,
+ geometryLimit: config.geometryLimit,
+ coordinatePrecision: config.coordinatePrecision,
+ minimalProperties: config.minimalProperties,
+ defaults: config.defaults,
+ legend: config.legend,
+ }
+}
+
+// Re-export moved builders from sqlite-map
+export { buildTables, buildGeoFeatures } from '../sqlite-map/feature-builder'
+
+// Re-export database functions from sqlite-map for convenience
+export { openDb, releaseDb, getCachedJoinData } from '../sqlite-map/db'
+export {
+ initSql,
+ releaseSql,
+ acquireLoadingSlot,
+ mapLoadingComplete,
+ getTotalMapsLoading,
+} from '../sqlite-map/loader'
diff --git a/src/plugins/plotly/PlotlyDiagram.vue b/src/plugins/plotly/PlotlyDiagram.vue
index 5f401671..d0f1ae16 100644
--- a/src/plugins/plotly/PlotlyDiagram.vue
+++ b/src/plugins/plotly/PlotlyDiagram.vue
@@ -196,6 +196,9 @@ const MyComponent = defineComponent({
// merge user-supplied layout with SimWrapper layout defaults
if (this.vizDetails.layout) this.mergeLayouts()
+ // Apply top-level axis range settings (xMin/xMax/yMin/yMax) even without a layout property
+ this.applyAxisRangeSettings()
+
if (this.vizDetails.fixedRatio) {
this.vizDetails.layout.xaxis = Object.assign(this.vizDetails.layout.xaxis, {
constrain: 'domain',
@@ -259,6 +262,16 @@ const MyComponent = defineComponent({
// Update the 'minXValue' if the minimum value in the 'x' array of the current trace is less than the current 'minXValue'.
if (xAxisMin <= this.minXValue) this.minXValue = xAxisMin
+
+ // optionally, if a max/min is set for the traces, collect the greatest maxes and least mins to build the layout ranges
+ if (this.traces[i].xaxis_max !== undefined && this.traces[i].xaxis_max > this.maxXValue)
+ this.maxXValue = this.traces[i].xaxis_max
+ if (this.traces[i].yaxis_max !== undefined && this.traces[i].yaxis_max > this.maxYValue)
+ this.maxYValue = this.traces[i].yaxis_max
+ if (this.traces[i].xaxis_min !== undefined && this.traces[i].xaxis_min < this.minXValue)
+ this.minXValue = this.traces[i].xaxis_min
+ if (this.traces[i].yaxis_min !== undefined && this.traces[i].yaxis_min < this.minYValue)
+ this.minYValue = this.traces[i].yaxis_min
}
// Set the x-axis and y-axis ranges in the layout based on the calculated 'minXValue', 'maxXValue', 'minYValue', and 'maxYValue'.
@@ -283,6 +296,8 @@ const MyComponent = defineComponent({
// this.maxYValue +
// ']'
// )
+
+
},
changeDimensions(dim: any) {
if (dim?.height && dim?.width) {
@@ -308,20 +323,56 @@ const MyComponent = defineComponent({
delete mergedLayout.height
delete mergedLayout.width
+ // Apply top-level xMin/xMax/yMin/yMax if provided
+ const { xMin, xMax, yMin, yMax } = this.vizDetails
+ const hasXRange = xMin !== undefined || xMax !== undefined
+ const hasYRange = yMin !== undefined || yMax !== undefined
+
// be selective about these:
if (mergedLayout.xaxis) {
mergedLayout.xaxis.automargin = true
- mergedLayout.xaxis.autorange = true
mergedLayout.xaxis.animate = true
+ // Only set autorange if no range is specified (via xMin/xMax or layout.xaxis.range)
+ const xRangeFromLayout = mergedLayout.xaxis.range && mergedLayout.xaxis.range.length === 2
+ if (hasXRange) {
+ mergedLayout.xaxis.range = [
+ xMin !== undefined ? xMin : null,
+ xMax !== undefined ? xMax : null
+ ]
+ mergedLayout.xaxis.autorange = false
+ } else if (xRangeFromLayout) {
+ mergedLayout.xaxis.autorange = false
+ } else {
+ mergedLayout.xaxis.autorange = true
+ }
if (!mergedLayout.xaxis.title) mergedLayout.xaxis.title = this.layout.xaxis.title
} else {
- mergedLayout.xaxis = this.layout.xaxis
+ mergedLayout.xaxis = { ...this.layout.xaxis }
+ if (hasXRange) {
+ mergedLayout.xaxis.range = [
+ xMin !== undefined ? xMin : null,
+ xMax !== undefined ? xMax : null
+ ]
+ mergedLayout.xaxis.autorange = false
+ }
}
if (mergedLayout.yaxis) {
mergedLayout.yaxis.automargin = true
- mergedLayout.yaxis.autorange = true
mergedLayout.yaxis.animate = true
+ // Only set autorange if no range is specified (via yMin/yMax or layout.yaxis.range)
+ const yRangeFromLayout = mergedLayout.yaxis.range && mergedLayout.yaxis.range.length === 2
+ if (hasYRange) {
+ mergedLayout.yaxis.range = [
+ yMin !== undefined ? yMin : null,
+ yMax !== undefined ? yMax : null
+ ]
+ mergedLayout.yaxis.autorange = false
+ } else if (yRangeFromLayout) {
+ mergedLayout.yaxis.autorange = false
+ } else {
+ mergedLayout.yaxis.autorange = true
+ }
// bug #357: scatterplots fail if rangemode is set
if (!this.traces.find(a => a?.type == 'scatter')) {
@@ -329,7 +380,14 @@ const MyComponent = defineComponent({
}
if (!mergedLayout.yaxis.title) mergedLayout.yaxis.title = this.layout.yaxis.title
} else {
- mergedLayout.yaxis = this.layout.yaxis
+ mergedLayout.yaxis = { ...this.layout.yaxis }
+ if (hasYRange) {
+ mergedLayout.yaxis.range = [
+ yMin !== undefined ? yMin : null,
+ yMax !== undefined ? yMax : null
+ ]
+ mergedLayout.yaxis.autorange = false
+ }
}
if (mergedLayout.yaxis2) {
@@ -345,6 +403,32 @@ const MyComponent = defineComponent({
this.layout = mergedLayout
},
+ /**
+ * Apply top-level axis range settings (xMin, xMax, yMin, yMax) from vizDetails.
+ * This handles the case where user doesn't provide a layout property but still wants axis ranges.
+ */
+ applyAxisRangeSettings() {
+ const { xMin, xMax, yMin, yMax } = this.vizDetails
+ const hasXRange = xMin !== undefined || xMax !== undefined
+ const hasYRange = yMin !== undefined || yMax !== undefined
+
+ if (hasXRange) {
+ this.layout.xaxis.range = [
+ xMin !== undefined ? xMin : null,
+ xMax !== undefined ? xMax : null
+ ]
+ this.layout.xaxis.autorange = false
+ }
+
+ if (hasYRange) {
+ this.layout.yaxis.range = [
+ yMin !== undefined ? yMin : null,
+ yMax !== undefined ? yMax : null
+ ]
+ this.layout.yaxis.autorange = false
+ }
+ },
+
// This method checks if facet_col and/or facet_row are defined in the traces
createFacets() {
if (this.traces[0].facet_col == undefined && this.traces[0].facet_row == undefined) return
diff --git a/src/plugins/pluginRegistry.ts b/src/plugins/pluginRegistry.ts
index 742dc9c8..489b8668 100644
--- a/src/plugins/pluginRegistry.ts
+++ b/src/plugins/pluginRegistry.ts
@@ -143,6 +143,11 @@ const plugins = [
],
component: defineAsyncComponent(() => import('./logistics/LogisticsViewer.vue')),
},
+ {
+ kebabName: "aeq-reader",
+ filePatterns: ['**/aeqviz-*.y?(a)ml'],
+ component: defineAsyncComponent(() => import('./aequilibrae/AequilibraEReader.vue')),
+ },
]
export const pluginComponents: { [key: string]: AsyncComponent } = {}
diff --git a/src/plugins/shape-file/DeckMapComponent.vue b/src/plugins/shape-file/DeckMapComponent.vue
index 77728fdf..2486dd5c 100644
--- a/src/plugins/shape-file/DeckMapComponent.vue
+++ b/src/plugins/shape-file/DeckMapComponent.vue
@@ -48,6 +48,8 @@ export default defineComponent({
redraw: { type: Number, required: true },
screenshot: { type: Number, required: true },
viewId: { type: Number, required: true },
+ lineWidthUnits: { type: String, required: false, default: 'pixels' },
+ pointRadiusUnits: { type: String, required: false, default: 'pixels' },
},
data() {
@@ -322,15 +324,15 @@ export default defineComponent({
this.highlightedLinkIndex == -1 ? null : this.highlightedLinkIndex,
autoHighlight: true,
highlightColor: [255, 255, 255, 160],
- lineWidthUnits: 'pixels',
+ lineWidthUnits: this.lineWidthUnits,
lineWidthScale: 1,
lineWidthMinPixels: 0, // typeof lineWidths === 'number' ? 0 : 1,
lineWidthMaxPixels: 50,
getOffset: OFFSET_DIRECTION.RIGHT,
opacity: this.opacity,
pickable: true,
- pointRadiusUnits: 'pixels',
- pointRadiusMinPixels: 2,
+ pointRadiusUnits: this.pointRadiusUnits,
+ pointRadiusMinPixels: 0,
// pointRadiusMaxPixels: 50,
stroked: this.isStroked,
// useDevicePixels: this.isTakingScreenshot,
@@ -386,7 +388,7 @@ export default defineComponent({
this.highlightedLinkIndex == -1 ? null : this.highlightedLinkIndex,
highlightColor: [255, 255, 255, 160], // [255, 0, 204, 255],
opacity: 1,
- widthUnits: 'pixels',
+ widthUnits: this.lineWidthUnits,
widthMinPixels: 1,
offsetDirection: OFFSET_DIRECTION.RIGHT,
transitions: {
diff --git a/src/plugins/sqlite-map/SqliteReader.vue b/src/plugins/sqlite-map/SqliteReader.vue
new file mode 100644
index 00000000..f42c2de9
--- /dev/null
+++ b/src/plugins/sqlite-map/SqliteReader.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/sqlite-map/color-utils.ts b/src/plugins/sqlite-map/color-utils.ts
new file mode 100644
index 00000000..83844d72
--- /dev/null
+++ b/src/plugins/sqlite-map/color-utils.ts
@@ -0,0 +1,99 @@
+// Color and encoder utilities extracted from styling.ts
+
+import * as cartoColors from 'cartocolor'
+import type { RGBA, RGB } from './types'
+
+export const hexToRgbA = (hex: string, alpha: number = 1): RGBA => {
+ const bytes = hex.replace('#', '').match(/.{1,2}/g) || ['80', '80', '80']
+ return [
+ parseInt(bytes[0], 16),
+ parseInt(bytes[1], 16),
+ parseInt(bytes[2], 16),
+ Math.round(alpha * 255),
+ ]
+}
+
+export const hexToRgb = (hex: string): RGB => {
+ const rgba = hexToRgbA(hex, 1)
+ return [rgba[0], rgba[1], rgba[2]]
+}
+
+export const hexToRgba = (hex: string, alpha: number = 1): RGBA => hexToRgbA(hex, alpha)
+
+export const getPaletteColors = (name: string, numColors: number): string[] => {
+ const palette = (cartoColors as any)[name || 'YlGn']
+ if (!palette) return Array(numColors).fill('#808080')
+ const sizes = Object.keys(palette)
+ .map(Number)
+ .filter(n => n > 0)
+ .sort((a, b) => a - b)
+ const size = sizes.find(s => s >= numColors) || sizes[sizes.length - 1]
+ return palette[size] || Array(numColors).fill('#808080')
+}
+
+// Small numeric helpers used by encoders
+export const toNumber = (value: any): number | null => {
+ const num = Number(value)
+ return isNaN(num) ? null : num
+}
+
+const safeMin = (arr: number[]): number => {
+ if (arr.length === 0) return 0
+ let min = arr[0]
+ for (let i = 1; i < arr.length; i++) {
+ if (arr[i] < min) min = arr[i]
+ }
+ return min
+}
+
+const safeMax = (arr: number[]): number => {
+ if (arr.length === 0) return 1
+ let max = arr[0]
+ for (let i = 1; i < arr.length; i++) {
+ if (arr[i] > max) max = arr[i]
+ }
+ return max
+}
+
+export const buildColorEncoder = (
+ values: any[],
+ style: any,
+ dataRange: [number, number] | null = null
+) => {
+ if (dataRange) {
+ values = values.map(v => {
+ const num = toNumber(v)
+ if (num === null) return null
+ return Math.max(dataRange[0], Math.min(dataRange[1], num))
+ })
+ }
+
+ const nums = values.map(toNumber).filter((v): v is number => v !== null)
+ const s = style || {}
+ const [min, max] = s.range ? s.range : [safeMin(nums), safeMax(nums)]
+
+ const paletteName = s.palette || 'YlGn'
+ const numColors = s.numColors || 7
+ const colors = getPaletteColors(paletteName, numColors).map((h: string) => hexToRgba(h, 1))
+
+ const scale = max === min ? 0 : (numColors - 1) / (max - min)
+
+ return (value: any) => {
+ const num = toNumber(value) ?? min
+ const idx = Math.round((num - min) * scale)
+ return colors[Math.max(0, Math.min(numColors - 1, idx))]
+ }
+}
+
+export const buildCategoryEncoder = (
+ colors: Record,
+ defaultColor: string = '#808080'
+) => {
+ const colorMap = new Map()
+ for (const [key, hex] of Object.entries(colors)) {
+ colorMap.set(String(key), hexToRgbA(hex, 1))
+ }
+ const defaultRgba = hexToRgbA(defaultColor, 1)
+
+ return (value: any) => colorMap.get(String(value)) || defaultRgba
+}
diff --git a/src/plugins/sqlite-map/db.ts b/src/plugins/sqlite-map/db.ts
new file mode 100644
index 00000000..9a855a14
--- /dev/null
+++ b/src/plugins/sqlite-map/db.ts
@@ -0,0 +1,622 @@
+/**
+ * Database utilities for AequilibraE plugin
+ *
+ * This module provides helper functions for interacting with SQLite databases
+ * containing spatial data and results. It includes functions for schema inspection,
+ * data querying, joining operations, and memory-optimized GeoJSON extraction.
+ *
+ * @fileoverview Database Utility Functions for AequilibraE
+ * @author SimWrapper Development Team
+ */
+
+import proj4 from 'proj4'
+import type { JoinConfig, GeoFeature, SqliteDb, SPL } from './types'
+import {
+ ESSENTIAL_SPATIAL_COLUMNS,
+ isGeometryColumn,
+ getUsedColumns,
+ simplifyCoordinates,
+ createJoinCacheKey,
+} from './utils'
+
+/**
+ * Retrieves all table names from a SQLite database
+ *
+ * @param db - SQLite database connection object
+ * @returns Promise Array of table names
+ */
+export async function getTableNames(db: SqliteDb): Promise {
+ const result = await db.exec("SELECT name FROM sqlite_master WHERE type='table';").get.objs
+ return result.map((row: any) => row.name)
+}
+
+/**
+ * Gets the schema (column information) for a specific table
+ *
+ * @param db - SQLite database connection object
+ * @param tableName - Name of the table to inspect
+ * @returns Promise with array of column metadata (name, type, nullable)
+ */
+export async function getTableSchema(
+ db: SqliteDb,
+ tableName: string
+): Promise<{ name: string; type: string; nullable: boolean }[]> {
+ const result = await db.exec(`PRAGMA table_info("${tableName}");`).get.objs
+ return result.map((row: any) => ({
+ name: row.name,
+ type: row.type,
+ nullable: row.notnull === 0,
+ }))
+}
+
+/**
+ * Counts the number of rows in a table
+ *
+ * @param db - SQLite database connection object
+ * @param tableName - Name of the table to count
+ * @returns Promise Number of rows in the table
+ */
+export async function getRowCount(db: SqliteDb, tableName: string): Promise {
+ const result = await db.exec(`SELECT COUNT(*) as count FROM "${tableName}";`).get.objs
+ return result.length > 0 ? result[0].count : 0
+}
+
+/**
+ * Query a table and return all rows as objects
+ *
+ * @param db - SQLite database connection object
+ * @param tableName - Name of the table to query
+ * @param columns - Optional array of column names to select (default: all columns)
+ * @param whereClause - Optional WHERE clause to filter results (without the WHERE keyword)
+ * @returns Promise[]> Array of row objects
+ */
+export async function queryTable(
+ db: SqliteDb,
+ tableName: string,
+ columns?: string[],
+ whereClause?: string
+): Promise[]> {
+ const columnList = columns ? columns.map(c => `"${c}"`).join(', ') : '*'
+ const whereCondition = whereClause ? ` WHERE ${whereClause}` : ''
+ const query = `SELECT ${columnList} FROM "${tableName}"${whereCondition};`
+ const result = await db.exec(query).get.objs
+ return result
+}
+
+/**
+ * Perform an in-memory join between main data and external data
+ * @param mainData - Array of records from the main table
+ * @param joinData - Array of records from the join table
+ * @param joinConfig - Configuration specifying join keys and type
+ * @returns Merged array with joined columns added to main records
+ */
+export function performJoin(
+ mainData: Record[],
+ joinData: Record[],
+ joinConfig: JoinConfig
+): Record[] {
+ const joinLookup = new Map>()
+ for (const row of joinData) {
+ const key = row[joinConfig.rightKey]
+ if (key !== undefined && key !== null) {
+ joinLookup.set(key, row)
+ }
+ }
+
+ return mainData
+ .map(mainRow => {
+ const joinRow = joinLookup.get(mainRow[joinConfig.leftKey])
+
+ if (!joinRow && joinConfig.type !== 'left') {
+ return null // Skip non-matching rows in inner join
+ }
+
+ if (!joinRow) {
+ return mainRow // Left join: keep main row without joined data
+ }
+
+ // Extract only the columns specified, or all columns
+ const joinedColumns = joinConfig.columns?.length
+ ? Object.fromEntries(
+ joinConfig.columns
+ .map(col => [col, joinRow[col]])
+ .filter(([_, value]) => value !== undefined)
+ )
+ : { ...joinRow }
+
+ return { ...mainRow, ...joinedColumns }
+ })
+ .filter((row): row is Record => row !== null)
+}
+
+// Cache for joinData
+const joinDataCache: Map>> = new Map()
+
+export function clearJoinCache(key?: string) {
+ if (key) {
+ joinDataCache.delete(key)
+ } else {
+ joinDataCache.clear()
+ }
+}
+
+export function getJoinCacheStats() {
+ return {
+ keys: Array.from(joinDataCache.keys()),
+ size: joinDataCache.size,
+ }
+}
+
+/**
+ * Get cached joinData or load it if not cached
+ * Accepts an optional neededColumn to reduce memory by querying fewer columns.
+ */
+export async function getCachedJoinData(
+ db: SqliteDb,
+ joinConfig: JoinConfig,
+ neededColumn?: string
+): Promise