diff --git a/package.json b/package.json
index 13133efba..ff65102f9 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 dc9f52cf4..8aee7f379 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 6f9cfc462..e733b4c24 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 0573d1075..9507f6d95 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-map.vue')),
carriers: defineAsyncComponent(() => import('./carriers.vue')),
flowmap: defineAsyncComponent(() => import('./flowmap.vue')),
links: defineAsyncComponent(() => import('./links.vue')),
diff --git a/src/dash-panels/aequilibrae-map.vue b/src/dash-panels/aequilibrae-map.vue
new file mode 100644
index 000000000..45f2ab9a3
--- /dev/null
+++ b/src/dash-panels/aequilibrae-map.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 e749104ff..c0e76f015 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'}")
@@ -22,9 +26,53 @@ import HTTPFileSystem from '@/js/HTTPFileSystem'
import globalStore from '@/store'
import { arrayBufferToBase64 } from '@/js/util'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { openDb } from '@/plugins/sqlite-map/db'
+import { initSql } from '@/plugins/sqlite-map/loader'
+import { loadDbWithCache } from '@/plugins/sqlite-map/helpers'
const BASE_URL = import.meta.env.BASE_URL
+// color palette options
+const PALETTE_PASTEL = [
+ '#F08080', // Light coral pink
+ '#FFB6C1', // Pale pink
+ '#FFDAB9', // peach
+ '#FFECB3', // cream yellow
+ '#B0E0E6', // light blue
+ '#98FB98', // light green
+ '#FFD700', // golden yellow
+ '#FFA07A', // salmon pink
+ '#E0FFFF', // light turquoise
+ '#FFDAB9', // pink
+ '#FFC0CB', // pink
+ '#FFA500', // orange
+ '#FF8C00', // dark orange
+ '#FF7F50', // coral red
+ '#FFE4B5', // papaya
+ '#ADD8E6', // light blue
+ '#90EE90', // light green
+ '#FFD700', // golden yellow
+ '#FFC0CB', // pink
+ '#FFA500', // Orange
+]
+
+const PALETTE_VIVID = [
+ '#FF006E', // Vivid Pink
+ '#FB5607', // Vivid Orange
+ '#FFBE0B', // Vivid Yellow
+ '#8338EC', // Vivid Purple
+ '#3A86FF', // Vivid Blue
+ '#06FFA5', // Vivid Green
+ '#FF4365', // Vivid Red
+ '#00D9FF', // Vivid Cyan
+ '#FF1493', // Vivid Deep Pink
+ '#FF8C00', // Vivid Deep Orange
+]
+
+const PALETTE_MONOCHROME = [
+ '#f7f7fe', // Light gray
+]
+
export default defineComponent({
name: 'Tile',
components: { FontAwesomeIcon },
@@ -44,29 +92,9 @@ export default defineComponent({
// dataSet is either x,y or allRows[]
dataSet: {} as { data?: any; x?: any[]; y?: any[]; allRows?: any },
YAMLrequirementsOverview: { dataset: '' },
- colors: [
- '#F08080', // Light coral pink
- '#FFB6C1', // Pale pink
- '#FFDAB9', // peach
- '#FFECB3', // cream yellow
- '#B0E0E6', // light blue
- '#98FB98', // light green
- '#FFD700', // golden yellow
- '#FFA07A', // salmon pink
- '#E0FFFF', // light turquoise
- '#FFDAB9', // pink
- '#FFC0CB', // pink
- '#FFA500', // orange
- '#FF8C00', // dark orange
- '#FF7F50', // coral red
- '#FFE4B5', // papaya
- '#ADD8E6', // light blue
- '#90EE90', // light green
- '#FFD700', // golden yellow
- '#FFC0CB', // pink
- '#FFA500', // Orange
- ],
+ colors: PALETTE_PASTEL,
colorsD3: [
+ // TODO: remove? Is this being used?
'#1F77B4',
'#FF7F0E',
'#2CA02C',
@@ -117,10 +145,29 @@ 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()
- this.validateDataSet()
+ // Set color palette from config if specified, otherwise default to pastel
+ if (this.config.colors) {
+ const paletteKey = this.config.colors.toLowerCase()
+ if (paletteKey === 'vivid') {
+ this.colors = PALETTE_VIVID
+ } else if (paletteKey === 'monochrome') {
+ this.colors = PALETTE_MONOCHROME
+ } else {
+ // Default to pastel for any other value or unrecognized palette
+ this.colors = PALETTE_PASTEL
+ }
+ }
+
+ this.dataSet = await this.buildDataset()
+ // this.validateDataSet()
await this.loadImages()
this.$emit('isLoaded')
console.log(this.dataSet)
@@ -191,6 +238,79 @@ export default defineComponent({
return []
},
+ async getDataFromSQLQuery(
+ database: string,
+ query: string,
+ singleValue = true,
+ titleColumn = 'metric',
+ valueColumn = 'value'
+ ) {
+ try {
+ const trimmedQuery = query.trim()
+ // open a sqlite connection
+ const spl = await initSql()
+ // connect to database
+ const db = await loadDbWithCache(spl, this.fileApi, openDb, database)
+ // run query and return result
+ if (singleValue) {
+ const queryResult = await db.exec(trimmedQuery).get.first
+ return queryResult
+ } else {
+ const queryResult = await db.exec(trimmedQuery).get.objs
+ const results = []
+ for (const obj of queryResult) {
+ results.push([obj[titleColumn], obj[valueColumn]]) // table columns default to 'metric' and 'value'
+ }
+ return results
+ }
+ } catch (e) {
+ console.error('' + e)
+ this.$emit('error', 'Error querying database: ' + database)
+ }
+ return { data: [] }
+ },
+
+ async buildDataset() {
+ // Datasets can be defined in a handful of ways.
+ // If `dataset` value is a string, it's a .csv to load.
+ if (typeof this.config.dataset === 'string') {
+ return await this.loadFile()
+ }
+ // It can be database & sql query
+ if (this.config.dataset.database && this.config.dataset.query) {
+ return {
+ data: await this.getDataFromSQLQuery(
+ this.config.dataset.database,
+ this.config.dataset.query,
+ false,
+ this.config.dataset.titleCol || 'metric',
+ this.config.dataset.valueCol || 'value'
+ ),
+ }
+ }
+ // Otherwise it's a list of key-value pairs.
+ // Values can either be static or be a database & sql query returning a single value.
+ if (Array.isArray(this.config.dataset)) {
+ const data: any[] = await Promise.all(
+ this.config.dataset.map(async (item: any) => {
+ const key = item.key
+ const row: any[] = []
+ row.push(key)
+ // if the database/query are defined
+ if (item.value?.database && item.value?.query) {
+ const result = await this.getDataFromSQLQuery(item.value.database, item.value.query)
+ row.push(result)
+ } else {
+ // otherwise it's a static value
+ row.push(item.value)
+ }
+ return row
+ })
+ )
+ return { data: data }
+ }
+ },
+
validateYAML() {
for (const key in this.YAMLrequirementsOverview) {
if (key in this.config === false) {
@@ -227,6 +347,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 +405,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 +420,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;
diff --git a/src/fileSystemConfig.ts b/src/fileSystemConfig.ts
index 5b25051d6..6e109b4f9 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 ecf7bbf28..e5cbbcddc 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/plugins/aequilibrae-map/AequilibraEMapComponent.vue b/src/plugins/aequilibrae-map/AequilibraEMapComponent.vue
new file mode 100644
index 000000000..258275b11
--- /dev/null
+++ b/src/plugins/aequilibrae-map/AequilibraEMapComponent.vue
@@ -0,0 +1,205 @@
+
+.c-aequilibrae-viewer.flex-col(:class="{'is-thumbnail': thumbnail}")
+ .map-viewer
+ SqliteMapComponent(
+ ref="sqliteReader"
+ :config="vizConfig"
+ :subfolder="subfolder"
+ :fileApi="fileApi"
+ @isLoaded="$emit('isLoaded')"
+ v-slot="{ geoJsonFeatures, fillColors, lineColors, lineWidths, pointRadii, fillHeights, featureFilter, isRGBA, redrawCounter, legendItems: slotLegendItems, initialView }"
+ )
+ DeckMapComponent(
+ ref="deckMap"
+ v-if="geoJsonFeatures.length && bgLayers && layerId"
+ :features="geoJsonFeatures"
+ :bgLayers="bgLayers"
+ :cbTooltip="handleTooltip"
+ :cbClickEvent="handleFeatureClick"
+ :dark="globalState.isDarkMode"
+ :featureFilter="featureFilter"
+ :fillColors="fillColors"
+ :fillHeights="fillHeights"
+ :highlightedLinkIndex="-1"
+ :initialView="initialView"
+ :isRGBA="isRGBA"
+ :isAtlantis="false"
+ :lineColors="lineColors"
+ :lineWidths="lineWidths"
+ :mapIsIndependent="false"
+ :opacity="1"
+ :pointRadii="pointRadii"
+ :redraw="redrawCounter"
+ :screenshot="0"
+ :viewId="layerId"
+ :lineWidthUnits="'meters'"
+ :pointRadiusUnits="'meters'"
+ )
+ template(v-if="slotLegendItems.length")
+ // Store legend items for use outside the slot
+ .legend-overlay(v-if="currentLegendItems.length" :style="{background: legendBgColor}")
+ LegendColors(:items="currentLegendItems" title="Legend")
+
+
+
+
+
diff --git a/src/plugins/aequilibrae-map/parseYaml.ts b/src/plugins/aequilibrae-map/parseYaml.ts
new file mode 100644
index 000000000..1f3e03594
--- /dev/null
+++ b/src/plugins/aequilibrae-map/parseYaml.ts
@@ -0,0 +1,37 @@
+import YAML from 'yaml'
+import type { VizDetails, LayerConfig } from '../sqlite-map/types'
+import { resolvePath, resolvePaths } from '../sqlite-map/utils'
+
+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,
+ projection: config.projection,
+ bearing: config.bearing,
+ pitch: config.pitch,
+ geometryLimit: config.geometryLimit,
+ minimalProperties: config.minimalProperties,
+ legend: config.legend,
+ }
+}
diff --git a/src/plugins/plotly/PlotlyDiagram.vue b/src/plugins/plotly/PlotlyDiagram.vue
index 5f4016714..d0f1ae16a 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 742dc9c8d..636e83bf0 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-map/AequilibraEMapComponent.vue')),
+ },
]
export const pluginComponents: { [key: string]: AsyncComponent } = {}
diff --git a/src/plugins/shape-file/DeckMapComponent.vue b/src/plugins/shape-file/DeckMapComponent.vue
index 77728fdfd..1077c8bb5 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,14 +324,14 @@ 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',
+ pointRadiusUnits: this.pointRadiusUnits,
pointRadiusMinPixels: 2,
// pointRadiusMaxPixels: 50,
stroked: this.isStroked,
@@ -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/SqliteMapComponent.vue b/src/plugins/sqlite-map/SqliteMapComponent.vue
new file mode 100644
index 000000000..14f73c310
--- /dev/null
+++ b/src/plugins/sqlite-map/SqliteMapComponent.vue
@@ -0,0 +1,293 @@
+
+.sqlite-reader
+ .loading(v-if="loading") {{ loadingText }}
+ slot(
+ v-if="!loading"
+ :geoJsonFeatures="geoJsonFeatures"
+ :legendItems="legendItems"
+ :fillColors="fillColors"
+ :lineColors="lineColors"
+ :lineWidths="lineWidths"
+ :pointRadii="pointRadii"
+ :fillHeights="fillHeights"
+ :featureFilter="featureFilter"
+ :isRGBA="isRGBA"
+ :redrawCounter="redrawCounter"
+ :initialView="initialView"
+ )
+
+
+
+
+
diff --git a/src/plugins/sqlite-map/db.ts b/src/plugins/sqlite-map/db.ts
new file mode 100644
index 000000000..ef9ed5f6c
--- /dev/null
+++ b/src/plugins/sqlite-map/db.ts
@@ -0,0 +1,394 @@
+import proj4 from 'proj4'
+import type { JoinConfig, GeoFeature, SqliteDb, SPL } from './types'
+import {
+ ESSENTIAL_SPATIAL_COLUMNS,
+ isGeometryColumn,
+ getUsedColumns,
+ createJoinCacheKey,
+} from './utils'
+
+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)
+}
+
+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,
+ }))
+}
+
+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
+}
+
+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
+}
+
+const joinDataCache: Map>> = new Map()
+
+export async function getCachedJoinData(
+ db: SqliteDb,
+ joinConfig: JoinConfig,
+ neededColumn?: string
+): Promise