diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 77aca2bac..b82bec9ff 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -11,17 +11,11 @@ jobs: name: Build & Run Tests runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x] - steps: - - uses: actions/checkout@v2 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 with: - node-version: ${{ matrix.node-version }} + node-version: '18' - name: Install Dependencies run: npm install diff --git a/api/api.js b/api/api.js index 5787ffbc6..d25f1925b 100644 --- a/api/api.js +++ b/api/api.js @@ -110,13 +110,6 @@ module.exports = async (req, res) => { // Assign from _template if provided as path param. req.params.template ??= req.params._template - // Decode string params. - Object.entries(req.params) - .filter(entry => typeof entry[1] === 'string') - .forEach(entry => { - req.params[entry[0]] = decodeURIComponent(entry[1]) - }) - // Short circuit login view or post request. if (req.params.login || req.body && req.body.login) return login(req, res) diff --git a/lib/layer/decorate.mjs b/lib/layer/decorate.mjs index a2530a8ab..354fe8274 100644 --- a/lib/layer/decorate.mjs +++ b/lib/layer/decorate.mjs @@ -14,6 +14,7 @@ export default async layer => { zoomToExtent, }); + // Warn if outdated layer.edit configuration is used. if (layer.edit) { console.warn(`Layer: ${layer.key}, please update edit:{} to use draw:{} as layer.edit has been superseeded with layer.draw to be in line with the OL drawing interaction.`) layer.draw = Object.assign(layer.draw || {}, layer.edit) @@ -22,6 +23,7 @@ export default async layer => { // Layer must have an empty draw config to allow for role based assignment of drawing methods. layer.draw ??= {} + // Warn if outdated layer.draw.delete configuration is used. if (layer.draw?.delete) { console.warn(`Layer: ${layer.key}, please move draw.delete to use layer.deleteLocation:true.`) } @@ -29,8 +31,10 @@ export default async layer => { // Callback which creates and stores a location from a feature returned by the drawing interaction. layer.draw.callback = async (feature, params) => { + // If the feature is null, return. if (!feature) return; + // Get the current table and set new to true. const location = { layer, table: layer.tableCurrent(), @@ -56,7 +60,8 @@ export default async layer => { layer.draw?.defaults || {})) }) - //layer.reload() + // Layer must be reloaded to reflect geometry changes. + layer.reload() // Get the newly created location. mapp.location.get(location) @@ -70,6 +75,20 @@ export default async layer => { // Set layer opacity from style. layer.L.setOpacity(layer.style?.opacity || 1); + // Layer style has multiple hovers and a single hover (incorrect configuration) + if (layer.style?.hovers && layer.style?.hover) { + + console.warn(`Layer: ${layer.key}, cannot use both layer.style.hover and layer.style.hovers. Layer.style.hover has been deleted.`) + delete layer.style.hover; + } + + // Layer style has multiple labels and a single label (incorrect configuration) + if (layer.style?.labels && layer.style?.label) { + + console.warn(`Layer: ${layer.key}, cannot use both layer.style.label and layer.style.labels. Layer.style.label has been deleted.`) + delete layer.style.label; + } + // Layer style has multiple themes. if (layer.style?.themes) { @@ -80,16 +99,15 @@ export default async layer => { : layer.style.themes[layer.style.theme || Object.keys(layer.style.themes)[0]]; } + // If setLabel is included and labels object exists. if (layer.style?.theme?.setLabel && layer.style?.labels) { + // Swap the label based on the setLabel key. layer.style.label = layer.style.labels[layer.style.theme.setLabel] } - if (layer.style?.theme?.setHover && layer.style?.hovers) { - - layer.style.hover = layer.style.hovers[layer.style.theme.setHover] - } - + // Warn if outdated layer.hover configuration is used. + // Set layer.style.hover and remove layer.hover. if (layer.hover) { console.warn(`Layer: ${layer.key}, layer.hover{} should be defined within layer.style{}.`) @@ -97,7 +115,15 @@ export default async layer => { delete layer.hover; } - // Layer style has multiple themes. + // If setHover is included and hovers object exists. + if (layer.style?.theme?.setHover && layer.style?.hovers) { + + // Swap the hover based on the setHover key. + layer.style.hover = layer.style.hovers[layer.style.theme.setHover] + } + + + // Layer style has multiple hovers if (layer.style?.hovers) { // Keep object hover. @@ -113,7 +139,7 @@ export default async layer => { layer.style.hover.method ??= mapp.layer.featureHover; } - // Layer style has multiple themes. + // Layer style has multiple labels. if (layer.style?.labels) { // Keep object label. @@ -131,7 +157,7 @@ export default async layer => { // Check if the role is an object and not null if (layer.roles[role] !== null && typeof layer.roles[role] === 'object') { - + // Extract the role name from negated roles (e.g., "!role" becomes "role") const negatedRole = role.match(/(?<=^!)(.*)/g)?.[0]; @@ -191,13 +217,12 @@ export default async layer => { } function show() { - /** - * Reveals the layer - */ + // Show the layer this.display = true; - try { // Add layer to map + try { + // Add layer to map this.mapview.Map.addLayer(this.L); } catch { // Will catch assertation error when layer is already added. @@ -217,10 +242,8 @@ function show() { } function hide() { - /** - * Hides the layer - */ + // Hide the layer. this.display = false; // Remove OL layer from mapview. @@ -237,9 +260,8 @@ function hide() { } function tableCurrent() { - /** - * Returns the current table - */ + + // Return the current table if it exists. // A layer must have either a table or tables configuration. if (!this.tables) return this.table; @@ -269,9 +291,8 @@ function tableCurrent() { } function tableMax() { - /** - * Returns the max table - */ + + // Returns the max table // A layer must have either a table or tables configuration. if (!this.tables) return this.table; @@ -281,9 +302,8 @@ function tableMax() { } function tableMin() { - /** - * Returns the min table - */ + + // Returns the min table. // A layer must have either a table or tables configuration. if (!this.tables) return this.table; @@ -293,9 +313,8 @@ function tableMin() { } async function zoomToExtent(params) { - /** - * Zooms to a specific extent - */ + + // Zooms to a specific extent // XMLHttpRequest to layer extent endpoint let response = await mapp.utils.xhr(`${this.mapview.host}/api/query/layer_extent?` + diff --git a/lib/layer/featureHover.mjs b/lib/layer/featureHover.mjs index 4f52a7d94..df8e2b071 100644 --- a/lib/layer/featureHover.mjs +++ b/lib/layer/featureHover.mjs @@ -3,12 +3,12 @@ export default function (feature, layer) { // The hover method must only execute if the display flag is set. if (!layer.style.hover.display) return; - if (!layer.mapview.interaction.current) return; + // Store current highlight (feature) key. + const featureKey = layer.mapview.interaction?.current?.key?.toString() - // Store current highlight key. - let key = layer.mapview.interaction.current.key.toString() + if (!featureKey) return; - let paramString = mapp.utils.paramString({ + const paramString = mapp.utils.paramString({ dbs: layer.dbs, locale: layer.mapview.locale.key, layer: layer.key, @@ -29,20 +29,23 @@ export default function (feature, layer) { mapp.utils.xhr(`${layer.mapview.host}/api/query?${paramString}`) .then(response => { - // Check whether highlight feature is still current. - if (layer.mapview.interaction?.current?.key !== key) return; - - // Check whether cursor has position (in map). - if (!layer.mapview.position) return; - // Check whether there is a response to display. if (!response) return; + if (typeof layer.style.hover.render === 'function') { + + const content = layer.style.hover.render(response) + layer.mapview.infotip(content) + return; + } + // Check whether the response label field has a value. - if (response.label == '') return; + if (response.label == '') return; + + // Check whether highlight feature is still current. + if (layer.mapview.interaction?.current?.key !== featureKey) return; // Display the response label as infotip. layer.mapview.infotip(response.label) }) - } \ No newline at end of file diff --git a/lib/layer/format/_format.mjs b/lib/layer/format/_format.mjs index 628e37a5f..c9988c163 100644 --- a/lib/layer/format/_format.mjs +++ b/lib/layer/format/_format.mjs @@ -15,5 +15,6 @@ export default { mvt, cluster: vector, geojson: vector, - wkt: vector + wkt: vector, + vector } \ No newline at end of file diff --git a/lib/layer/format/vector.mjs b/lib/layer/format/vector.mjs index 76394e2d4..9f91834a8 100644 --- a/lib/layer/format/vector.mjs +++ b/lib/layer/format/vector.mjs @@ -1,35 +1,19 @@ export default layer => { - if (!layer.srid) { - console.warn(`No SRID provided for ${layer.key}`) - } + // 3857 is assumed to be the default SRID for all vector format layer. + layer.srid ??= '3857' if (layer.properties) { - console.warn(`Layer: ${layer.key},layer.properties{} are no longer required for wkt & geojson datasets.`) + console.warn(`Layer: ${layer.key}, layer.properties{} are no longer required for wkt & geojson datasets.`) } + // Set default layer params if nullish. layer.params ??= {} - // If layer configuration is wrong and contains both cluster.distance and cluster.resolution, error and return - if (layer.cluster?.distance && layer.cluster?.resolution) { - - console.error(`Layer: ${layer.key}, cluster.distance and cluster.resolution are mutually exclusive. You cannot use them both on the same layer. Please remove one of them. `) - - return; - }; - - if (layer.cluster?.resolution) { - layer.format = 'cluster'; - layer.params.viewport = true; - layer.params.z = true; - layer.params.resolution = layer.cluster.resolution; - layer.params.template = layer.cluster.hexgrid ? 'cluster_hex' : 'cluster'; - } + clusterConfig(layer) // Assign style object if nullish. - layer.style ??= {} - - layer.srid ??= '4326' + layer.style ??= {}; layer.setSource = (features) => { @@ -106,6 +90,7 @@ export default layer => { locale: layer.mapview.locale.key, layer: layer.key, table, + srid: layer.srid, filter: layer.filter?.current, ...layer.params })}`) @@ -144,19 +129,11 @@ export default layer => { } // Change method for the cluster feature properties and layer stats. - layer.L.on('change', e => { - - // Do not process cluster for non cluster layers. - if (!layer.cluster) return; + layer.cluster?.distance && layer.L.on('change', e => { // To prevent layer.L.change() from crashing if called before data is loaded. if (!layer.cluster.source) return; - // The OL cluster must not be processed with a resolution. - if (layer.cluster.resolution) return; - - if (!layer.cluster?.distance) return; - delete layer.max_size; const feature_counts = layer.cluster.source.getFeatures().map(F => { @@ -191,5 +168,53 @@ export default layer => { // Calculate max_size for cluster size styling. layer.max_size = Math.max(...feature_counts) }) +} + +function clusterConfig(layer) { + // The clusterConfig can not work without the layer having a cluster config object. + if (typeof layer.cluster !== 'object') return; + + // Check if both cluster.distance and cluster.resolution are set. + if (layer.cluster.distance && layer.cluster.resolution) { + console.warn(`Layer: ${layer.key}, cluster.distance and cluster.resolution are mutually exclusive. You cannot use them both on the same layer. Please remove one of them. `) + return; + }; + + // Check if neither cluster.distance and cluster.resolution are set. + if (!layer.cluster.distance && !layer.cluster.resolution) { + console.warn(`Layer: ${layer.key}, cluster.distance or cluster.resolution must be set.`) + return; + }; + + // If cluster.resolution is used, the layer srid must be set to 3857. + if (layer.cluster.resolution) { + + // Check if resolution is numeric. + if (typeof layer.cluster.resolution === 'number') { + // Assign resolution as float. + layer.params.resolution = parseFloat(layer.cluster.resolution); + } + // Otherwise, warn and return. + else { + console.warn(`Layer: ${layer.key}, cluster.resolution must be a number.`) + return; + } + + // Check if srid is set to 4326, not allowed for cluster layer + if (layer.srid === '4326') { + console.warn(`Layer: ${layer.key}, srid 4326 is not allowed for cluster.resolution layers.`) + return; + }; + + // Provide default params for resolution cluster. + layer.params.viewport = true; + layer.params.z = true; + + // Format is cluster if resolution is set. + layer.format = 'cluster'; + + // Assign default template. + layer.params.template ??= layer.cluster.hexgrid ? 'cluster_hex' : 'cluster'; + } } \ No newline at end of file diff --git a/lib/layer/themes/distributed.mjs b/lib/layer/themes/distributed.mjs index a6292224a..24761004e 100644 --- a/lib/layer/themes/distributed.mjs +++ b/lib/layer/themes/distributed.mjs @@ -1,31 +1,29 @@ export default function(theme, feature) { if (!theme.lookup) { - theme.lookup = {} theme.boxes = [] theme.index = 0 } + // Get feature identifier for theme. + const ID = feature.properties[theme.field || 'id'] || 0 + // The feature field property value already has a style assigned. - if (theme.lookup[feature.properties[theme.field]]) { + if (theme.lookup[ID]) { // Assign style from lookup object. - var catStyle = theme.lookup[feature.properties[theme.field]] - mapp.utils.merge(feature.style, catStyle) + mapp.utils.merge(feature.style, theme.lookup[ID]) return; } // Get feature bounding box from geometry extent. - let bbox = { + const bbox = { extent: feature.getGeometry().getExtent() } - // add box to visual check layer. - // theme.source.addFeature(new ol.Feature(new ol.geom.Polygon.fromExtent(bbox.extent))) - // Find intersecting bounding boxes with their assigned cat index. - let intersects = theme.boxes.filter(b => !(bbox.extent[0] > b.extent[2] + const intersects = theme.boxes.filter(b => !(bbox.extent[0] > b.extent[2] || bbox.extent[2] < b.extent[0] || bbox.extent[1] > b.extent[3] || bbox.extent[3] < b.extent[1])) @@ -34,23 +32,24 @@ export default function(theme, feature) { theme.boxes.push(bbox) // Create a set of cat indices from intersecting bounding boxes. - let set = new Set(intersects.map(b => b.themeIdx)) + const set = new Set(intersects.map(b => b.themeIdx)) - // Increase the cat indix. + // Increase the current cat indix. theme.index++ // Reset cat index to 0 if the index reaches the length of the cat array. if (theme.index === theme.cat_arr.length) theme.index = 0 - // Check whether the set of intersecting bounding boxes has NOT the cat index. + // i is the cat index if not in set of intersecting boxes. let i = !(set.has(theme.index)) && parseInt(theme.index) - // Index is not available. + // Current index is already in set of intersecting boxes. if (i === false) { // Iterate through the cat array. for (let free = 0; free < theme.cat_arr.length; free++) { + // Find an index which is not in set of intersecting bbox indices. if (!set.has(free)) { // Assign free index and break loop. @@ -60,22 +59,19 @@ export default function(theme, feature) { } } - // No index is available. + // Any index is in set of intersecting box indices. if (i === false) { - // Assign cat index. + // Just assign the current index. It is not possible to prevent some neighbouring cats. i = parseInt(theme.index) } // Assign index to the bounding box which is stored in the array of bounding boxes. bbox.themeIdx = i - // Get the JSON style from cat array via index. - var catStyle = theme.cat_arr[i] - - // Assign the JSON style to the lookup object for the feature field property value. - theme.lookup[feature.properties[theme.field]] = catStyle + // Assign the style to the lookup object for the feature field property value. + theme.lookup[ID] = theme.cat_arr[i] // Merge the cat style with the feature style. - mapp.utils.merge(feature.style, catStyle) + mapp.utils.merge(feature.style, theme.lookup[ID]) } \ No newline at end of file diff --git a/lib/mapp.mjs b/lib/mapp.mjs index 0c8879556..afc8b0b3b 100644 --- a/lib/mapp.mjs +++ b/lib/mapp.mjs @@ -18,7 +18,7 @@ self.mapp = (function (mapp) { Object.assign(mapp, { version: '4.7.2', - hash: 'f836737e4c92e114f788dc6461ffcd5ecddba295', + hash: '4026463d6e57c02f0da8ad676d81f59e47baacc8', language: hooks.current.language || 'en', dictionaries, diff --git a/lib/mapview/infotip.mjs b/lib/mapview/infotip.mjs index 5c5977aa5..8b2234ec8 100644 --- a/lib/mapview/infotip.mjs +++ b/lib/mapview/infotip.mjs @@ -7,20 +7,42 @@ export default function(content) { // Remove infotip node element infotip.node?.remove() + // The mapview must have a position to place the infotip. + if (!mapview.position) return; + // Remove infotip positioning event from mapview Map. mapview.Map.un('pointermove', position) // Just clears the infotip. if (!content) return; - // Creates the infotip node. - infotip.node = mapp.utils.html.node`
` + if (content instanceof HTMLElement) { + + infotip.node = content + + // Content type object but not HTMLElement cannot be rendered. + } else if (typeof content === 'object') { + + console.log(content) + return; + + } else { - // Assigns the infotip content. - infotip.node.innerHTML = content + // Check for braces in content string. + if (/[()]/.test(content)) { + + console.warn(`Potential XSS detected in infotip content ${content}`) + } + + // Creates the infotip node. + infotip.node = mapp.utils.html.node`
` + + // Assigns the infotip content. + infotip.node.innerHTML = content + } // Appends the infotip to the mapview.Map. - mapview.Map.getTargetElement().appendChild(infotip.node) + mapview.Map.getTargetElement().append(infotip.node) // Assign infotip positioning event to mapview.Map. mapview.Map.on('pointermove', position) @@ -37,5 +59,4 @@ export default function(content) { infotip.node.style.left = mapview.pointerLocation.x - infotip.node.offsetWidth / 2 + 'px' infotip.node.style.top = mapview.pointerLocation.y - infotip.node.offsetHeight - 15 + 'px' } - } \ No newline at end of file diff --git a/lib/ui/Dataview.mjs b/lib/ui/Dataview.mjs index b9a200de1..9d2779dd1 100644 --- a/lib/ui/Dataview.mjs +++ b/lib/ui/Dataview.mjs @@ -15,12 +15,12 @@ export default async (_this) => { } // Assign queryparams from layer, locale. - _this.queryparams = Object.assign( - _this.queryparams || {}, - _this.layer?.queryparams || {}, - _this.layer?.mapview.locale.queryparams || {}, - _this.location?.layer?.queryparams || {}, - _this.location?.layer?.mapview.locale.queryparams || {}) + _this.queryparams = { + ..._this.queryparams, + ..._this.layer?.queryparams, + ..._this.layer?.mapview?.locale?.queryparams, + ..._this.location?.layer?.queryparams, + ..._this.location?.layer?.mapview?.locale?.queryparams} // Update method for _this. _this.update = async () => { @@ -75,7 +75,7 @@ export default async (_this) => { } // Create a ChartJS dataview is chart is defined. - if (_this.chart) await Chart(_this); + if (_this.chart) await mapp.ui.utils.Chart(_this); // Columns in entry indicate missing table config. if (typeof _this.columns !== 'undefined') { @@ -86,14 +86,14 @@ export default async (_this) => { } // Create a Tabulator dataview if columns are defined. - if (_this.table) await Table(_this); + if (_this.table) await mapp.ui.utils.Tabulator(_this); // Update the dataview on mapChange if set. - _this.mapChange && - _this.layer && - _this.layer.mapview.Map.getTargetElement().addEventListener( - 'changeEnd', - () => { + _this.mapChange + && _this.layer + && _this.layer.mapview.Map.getTargetElement() + .addEventListener('changeEnd', () => { + // Only update dataview if corresponding layer is visible. if (_this.layer && !_this.layer.display) return; @@ -103,209 +103,7 @@ export default async (_this) => { // Execute mapChange if defined as function or dataview update method. (typeof _this.mapChange === 'function' && _this.mapChange()) || _this.update(); - } - ); + }); return _this; -}; - -async function Chart(_this) { - // Charts most be rendered into a canvas type element. - const canvas = _this.target.appendChild(mapp.utils.html.node``); - - // Await initialisation of ChartJS object. - _this.ChartJS = await mapp.ui.utils.Chart( - canvas, - mapp.utils.merge( - { - type: 'bar', - options: { - plugins: { - legend: { - display: false, - }, - datalabels: { - display: false, - }, - }, - }, - }, - _this.chart - ) - ); - - // Set chart data - _this.setData = (data) => { - if (_this.noDataMask && !data) { - // Remove display from target - _this.target.style.display = 'none'; - - // Set no data mask on dataview target - _this.mask = - !_this.mask && - _this.target.parentElement?.appendChild(mapp.utils.html.node` -
No Data`); - } else { - // Remove existing dataview mask. - _this.mask && _this.mask.remove(); - delete _this.mask; - - // Set dataview target to display as block. - _this.target.style.display = 'block'; - } - - // Create a dataset with empty data array if data is falsy. - if (!data) { - data = { - datasets: [ - { - data: [], - }, - ], - }; - } - - // Set data in datasets array if no datasets are defined in data. - if (!data.datasets) { - data = { - datasets: [ - { - data: data, - }, - ], - }; - } - - _this.data = data; - - // Assign datasets from chart object to data.datasets. - _this.chart.datasets?.length && - data.datasets.forEach((dataset, i) => - Object.assign(dataset, _this.chart.datasets[i]) - ); - - // Get labels from chart if not defined in data. - data.labels = data.labels || _this.chart.labels; - - // Set data to chartjs object. - _this.ChartJS.data = data; - - // Update the chartjs object. - _this.ChartJS.update(); - }; -} - -async function Table(_this) { - - // Check for custom column methods. - _this.table.columns.forEach((col) => chkCol(col)); - - function chkCol(col) { - - // Column is an array of sub columns. - if (Array.isArray(col.columns)) { - - col.columns.forEach(col => chkCol(col)) - return; - } - - // Check for custom headerFilter matched in the ui utils. - if ( - typeof col.headerFilter === 'string' && - mapp.ui.utils.tabulator.headerFilter[col.headerFilter] - ) { - // Assign custom headerFilter from ui utils. - col.headerFilter = - mapp.ui.utils.tabulator.headerFilter[col.headerFilter](_this); - } - - // Check for custom formatter in the ui utils. - if ( - typeof col.formatter === 'string' && - mapp.ui.utils.tabulator.formatter[col.formatter] - ) { - // Assign custom formatter from ui utils. - col.formatter = - mapp.ui.utils.tabulator.formatter[col.formatter](_this); - } - } - - // Await initialisation of Tabulator object. - _this.Tabulator = await mapp.ui.utils.Tabulator( - _this.target, - Object.assign( - { - //renderVertical: 'basic', - //renderHorizontal: 'virtual', - selectable: false, - //data: [_this.data] - }, - _this.table - ) - ); - - // Table will not automatically redraw on resize. - if (_this.table.autoResize === false) { - let debounce = 0; - - // debounce resizeOberserver by 800. - _this.resizeObserver = new ResizeObserver(() => { - clearTimeout(debounce); - debounce = setTimeout(() => { - _this.target.offsetHeight > 9 && _this.Tabulator.redraw(); - }, 800); - }); - - _this.resizeObserver.observe(_this.target); - } - - // Assign tabulator events from object. - typeof _this.events === 'object' && - Object.entries(_this.events).forEach((event) => { - - // Get event method from tabulator utils. - if (typeof mapp.ui.utils.tabulator[event[1].util || event[1]] === 'function') { - - _this.Tabulator.on(event[0], mapp.ui.utils.tabulator[event[1].util || event[1]](_this, event[1])); - return; - } - - // Shortcircuit if events object value is not a function. - if (typeof event[1] !== 'function') return; - - // Key is event name. Value is the event function. - _this.Tabulator.on(event[0], event[1]); - }); - - // Set Tabulator data. - _this.setData = (data) => { - if (_this.noDataMask && !data) { - // Remove display from target - _this.target.style.display = 'none'; - - // Set no data mask on dataview target - _this.mask = - !_this.mask && - _this.target.parentElement?.appendChild(mapp.utils.html.node` -
No Data`); - } else { - // Remove existing dataview mask. - _this.mask && _this.mask.remove(); - delete _this.mask; - - // Set dataview target to display as block. - _this.target.style.display = 'block'; - } - - // Tabulator data must be an array. - data = (!data && []) || (data.length && data) || [data]; - - // Set data to the tabulator object - _this.Tabulator.setData(data); - - // Assign data to the dataview object - _this.data = data; - - typeof _this.setDataCallback === 'function' && _this.setDataCallback(_this); - }; -} +}; \ No newline at end of file diff --git a/lib/ui/elements/dropdown.mjs b/lib/ui/elements/dropdown.mjs index 39c5a2551..6fd5d7138 100644 --- a/lib/ui/elements/dropdown.mjs +++ b/lib/ui/elements/dropdown.mjs @@ -31,7 +31,7 @@ export default (params) => { // Set btn text to reflect selection or show placeholder. btn.querySelector('[data-id=header-span]') - .textContent = params.selectedTitles.size && Array.from(params.selectedTitles).map(v => decodeURIComponent(v)).join(', ') + .textContent = params.selectedTitles.size && Array.from(params.selectedTitles).map(v => v).join(', ') || params.span || params.placeholder // Execute callback method and pass array of current selection. diff --git a/lib/ui/layers/_layers.mjs b/lib/ui/layers/_layers.mjs index c0430bd52..565902e05 100644 --- a/lib/ui/layers/_layers.mjs +++ b/lib/ui/layers/_layers.mjs @@ -20,7 +20,6 @@ import reports from './panels/reports.mjs' import style from './panels/style.mjs' // Styles - import categorized from './legends/categorized.mjs' import distributed from './legends/distributed.mjs' diff --git a/lib/ui/layers/filters.mjs b/lib/ui/layers/filters.mjs index d32de5b12..51ef15d57 100644 --- a/lib/ui/layers/filters.mjs +++ b/lib/ui/layers/filters.mjs @@ -13,16 +13,12 @@ export default { let timeout; -function applyFilter(layer, zoom) { - clearTimeout(timeout); +function applyFilter(layer) { - // enable zoomToExtent button. - let btn = layer.view.querySelector('[data-id=zoomToExtent]') - if (btn) btn.disabled = false; + clearTimeout(timeout); // Debounce layer reload by 500 timeout = setTimeout(() => { - timeout = null; layer.reload(); layer.mapview.Map.getTargetElement().dispatchEvent(new Event('changeEnd')) }, 500); @@ -186,17 +182,26 @@ async function filter_in(layer, filter) { multi: true, placeholder: 'Select Multiple', entries: filter[filter.type].map(val => ({ - title: decodeURIComponent(val), - option: encodeURIComponent(val), + title: val, + option: val, selected: chkSet.has(val) })), callback: async (e, options) => { - Object.assign(layer.filter.current, { - [filter.field]:{ - [filter.type]: options - } - }) + if (!options.length) { + + // Remove empty array filter. + delete layer.filter.current[filter.field] + } else { + + // Set filter values array from options. + Object.assign(layer.filter.current, { + [filter.field]:{ + [filter.type]: options + } + }) + } + layer.reload() layer.mapview.Map.getTargetElement().dispatchEvent(new Event('changeEnd')) } @@ -204,8 +209,8 @@ async function filter_in(layer, filter) { } return filter[filter.type].map(val => mapp.ui.elements.chkbox({ - val: encodeURIComponent(val), - label: decodeURIComponent(val), + val: val, + label: val, checked: chkSet.has(val), onchange: (checked, val) => { diff --git a/lib/ui/locations/entries/dataview.mjs b/lib/ui/locations/entries/dataview.mjs index a740553c0..fd247ce62 100644 --- a/lib/ui/locations/entries/dataview.mjs +++ b/lib/ui/locations/entries/dataview.mjs @@ -62,6 +62,12 @@ export default entry => { entry.display && createTabAndShow(entry) }; + if (!entry.target) { + + console.warn('type:dataview entry must have a target') + return; + } + // Dataview will be rendered into location view. if (typeof entry.target === 'string') { diff --git a/lib/ui/locations/entries/text.mjs b/lib/ui/locations/entries/text.mjs index 707cc76bb..ec5d0ec9a 100644 --- a/lib/ui/locations/entries/text.mjs +++ b/lib/ui/locations/entries/text.mjs @@ -29,15 +29,15 @@ function edit(entry) { // Create dropdown from options. options(entry) - } + } // If options is empty array, we need to query the table to populate it. else { - // Query distinct field values from the layer table. + // We can query a particular template or Query distinct field values from the layer table. mapp.utils.xhr( `${entry.location.layer.mapview.host}/api/query?` + mapp.utils.paramString({ - template: 'distinct_values', + template: entry.edit.query || 'distinct_values', dbs: entry.location.layer.dbs, locale: entry.location.layer.mapview.locale.key, layer: entry.location.layer.key, @@ -49,8 +49,8 @@ function edit(entry) { // Return first value from object row as options array. entry.edit.options = [response].flat().map(row => { return Object.values(row)[0] - })//.filter(val => val !== null) - + }) + // Create dropdown from options. options(entry) }) @@ -81,20 +81,20 @@ function options(entry) { const optionEntries = entry.edit.options.map(option => ({ // Assign null if option is null. - title: option === null ? null : + title: option === null ? null : // Assign string as title. typeof option === 'string' && option - + // Assign first key as title. || Object.keys(option)[0], // Assign null if option is null. - option: option === null ? null : - + option: option === null ? null : + // Assign string as option. typeof option === 'string' && option - + // Assign first value as option. || Object.values(option)[0] })) diff --git a/lib/ui/utils/Chart.mjs b/lib/ui/utils/Chart.mjs index 52f7098b3..067aa32ec 100644 --- a/lib/ui/utils/Chart.mjs +++ b/lib/ui/utils/Chart.mjs @@ -1,12 +1,9 @@ let promise, Chart = null -export default async function(canvas, options) { +async function chart(canvas, options) { - // Return Chart method if defined. - if (Chart) return new Chart(canvas, options); - - // Create promise to load Chart library. - if (!promise) promise = new Promise(async resolve => { + // Assign promise to load ChartJS library if null. + promise ??= new Promise(resolve => { // Assign from window if Chart library is loaded from link if (window.Chart) { @@ -28,8 +25,6 @@ export default async function(canvas, options) { ]) .then(imports => { - // console.log(imports) - // Register imports imports[0].Chart.register(...imports[0].registerables); @@ -48,7 +43,90 @@ export default async function(canvas, options) { }) + // Await promise to load chart library. await promise + // Return ChartJS creator method. return new Chart(canvas, options); +} + +export default async function(_this) { + + // Charts most be rendered into a canvas type element. + const canvas = _this.target.appendChild(mapp.utils.html.node``); + + // Await initialisation of ChartJS object. + _this.ChartJS = await chart( + canvas, + mapp.utils.merge( + { + type: 'bar', + options: { + plugins: { + legend: { + display: false, + }, + datalabels: { + display: false, + }, + }, + }, + }, + _this.chart + ) + ); + + // Set chart data + _this.setData = (data) => { + + // data is falsy + if (_this.noDataMask && !data) { + + _this.noDataMask = typeof _this.noDataMask === 'string' ? _this.noDataMask : 'No Data'; + + // Remove display from target + _this.target.style.display = 'none'; + + // Create _this.mask if undefined. + _this.mask ??= mapp.utils.html.node`
${_this.noDataMask}` + + // Append _this.mask to the target parent. + _this.target.parentElement?.append(_this.mask) + + } else { + + // Remove _this.mask from dom. + _this.mask?.remove(); + + // Set dataview target to display as block. + _this.target.style.display = 'block'; + } + + // Create a dataset with empty data array if data is undefined. + data ??= { datasets: [{ data: [] }] }; + + // Set data in datasets array if no datasets are defined in data. + data.datasets ??= [{ data }] + + _this.data = data; + + // Assign datasets from chart object to data.datasets. + _this.chart.datasets?.length && data.datasets.forEach((dataset, i) => + Object.assign(dataset, _this.chart.datasets[i])); + + // Assign data.labels from chart if nullish. + data.labels ??= _this.chart.labels; + + // Set data to chartjs object. + _this.ChartJS.data = data; + + // Update the chartjs object. + _this.ChartJS.update(); + + // Execute setDataCallback method if defined as function. + typeof _this.setDataCallback === 'function' && _this.setDataCallback(_this); + }; + + // Set _this.data if provided. + _this.data && _this.setData(_this.data) } \ No newline at end of file diff --git a/lib/ui/utils/Tabulator.mjs b/lib/ui/utils/Tabulator.mjs index c8b92aa60..5af827848 100644 --- a/lib/ui/utils/Tabulator.mjs +++ b/lib/ui/utils/Tabulator.mjs @@ -1,36 +1,23 @@ let promise, Tabulator = null -export default async function() { +async function tabulator() { - // Return Chart method if defined. - if (Tabulator) { + // Create promise to load Tabulator library if null. + promise ??= new Promise(resolve => { - // Built Tabulator instance - let T = new Tabulator(...arguments); - - // Await for the Tabulator table instance to be built in Promise - await new Promise(resolve => T.on('tableBuilt', resolve)) - - // Return built Tabulator instance. - return T - } - - // Create promise to load Chart library. - if (!promise) promise = new Promise(async resolve => { - - // Assign from window if Chart library is loaded from link + // Assign from window if Tabulator library is loaded from link if (window.Tabulator) { Tabulator = window.Tabulator resolve() - + return } // Append the tabulator css to the document head. document.getElementsByTagName('HEAD')[0].append(mapp.utils.html.node` - `); + `); // Import Chart and plugins. Promise @@ -38,7 +25,7 @@ export default async function() { import('https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator_esm.min.js') ]) .then(imports => { - + Tabulator = imports[0].TabulatorFull resolve() @@ -46,31 +33,31 @@ export default async function() { .catch(error => { console.error(error.message) alert('Failed to load Tabulator library. Please reload the browser.') - }) - + }) + }) await promise // Built Tabulator instance - let T = new Tabulator(...arguments); + const Table = new Tabulator(...arguments); // Await for the Tabulator table instance to be built in Promise - await new Promise(resolve => T.on('tableBuilt', resolve)) + await new Promise(resolve => Table.on('tableBuilt', resolve)) // Find ul_parents that are positioned fixed in table header. - let ul_parents = T.element.querySelectorAll('.ul-parent') + const ul_parents = Table.element.querySelectorAll('.ul-parent') // Adjust fixed dropdowns on scroll. - ul_parents.length && T.on("scrollHorizontal", left => { + ul_parents.length && Table.on("scrollHorizontal", left => { // Get the table element bounds. - const table_bounds = T.element.getBoundingClientRect() + const table_bounds = Table.element.getBoundingClientRect() for (const ul_parent of ul_parents) { // Get the ul_parent bounds. - let header_bounds = ul_parent.getBoundingClientRect() + const header_bounds = ul_parent.getBoundingClientRect() // Get ul element itself const ul = ul_parent.querySelector('ul') @@ -83,9 +70,110 @@ export default async function() { } } - }); // Return built Tabulator instance. - return T -} \ No newline at end of file + return Table +} + +export default async function (_this) { + + // Apply tabulator column methods + mapp.ui.utils.tabulator.columns(_this) + + // Await initialisation of Tabulator object. + _this.Tabulator = await tabulator( + _this.target, + { + //renderVertical: 'basic', + //renderHorizontal: 'virtual', + selectable: false, + //data: _this.data, + ..._this.table + }); + + // Table will not automatically redraw on resize. + if (_this.table.autoResize === false) { + let debounce = 0; + + // debounce resizeOberserver by 800. + _this.resizeObserver = new ResizeObserver(() => { + clearTimeout(debounce); + debounce = setTimeout(() => { + _this.target.offsetHeight > 9 && _this.Tabulator.redraw(); + }, 800); + }); + + _this.resizeObserver.observe(_this.target); + } + + // Assign tabulator events from object. + events(_this) + + // Set Tabulator data. + _this.setData = setData + + // Set _this.data if provided. + _this.data && _this.setData(_this.data) +} + +function events(_this) { + + if (typeof _this.events !== 'object') return; + + Object.entries(_this.events).forEach((event) => { + + // Get event method from tabulator utils. + if (typeof mapp.ui.utils.tabulator[event[1].util || event[1]] === 'function') { + + _this.Tabulator.on(event[0], + mapp.ui.utils.tabulator[event[1].util || event[1]](_this, event[1])); + return; + } + + // Shortcircuit if events object value is not a function. + if (typeof event[1] !== 'function') return; + + // Key is event name. Value is the event function. + _this.Tabulator.on(event[0], event[1]); + }); +} + +function setData(data) { + + if (this.noDataMask && !data) { + + this.noDataMask = typeof this.noDataMask === 'string' ? this.noDataMask : 'No Data'; + + // Remove display from target + this.target.style.display = 'none'; + + // Create this.mask if undefined. + this.mask ??= mapp.utils.html.node`
${this.noDataMask}` + + // Append this.mask to the target parent. + this.target.parentElement?.append(this.mask) + + } else { + + // Remove this.mask from dom. + this.mask?.remove(); + + // Set dataview target to display as block. + this.target.style.display = 'block'; + } + + // Set data as empty array if nullish. + data ??= [] + + // Make an array of data if not already an array. + data &&= Array.isArray(data) ? data : [data]; + + // Set data to the tabulator object + this.Tabulator.setData(data); + + this.data = data; + + // Execute setDataCallback method if defined as function. + typeof this.setDataCallback === 'function' && this.setDataCallback(_this); +}; \ No newline at end of file diff --git a/lib/ui/utils/tabulatorUtils.mjs b/lib/ui/utils/tabulatorUtils.mjs index 784cedc56..a75c7a806 100644 --- a/lib/ui/utils/tabulatorUtils.mjs +++ b/lib/ui/utils/tabulatorUtils.mjs @@ -9,7 +9,42 @@ export default { toLocalString, date }, - select + select, + columns +} + +function columns(_this) { + + // Check for custom column methods. + _this.table.columns.forEach((col) => chkCol(col)); + + function chkCol(col) { + + // Column is an array of sub columns. + if (Array.isArray(col.columns)) { + + col.columns.forEach(col => chkCol(col)) + return; + } + + // Check for custom headerFilter matched in the ui utils. + if (typeof col.headerFilter === 'string' + && mapp.ui.utils.tabulator.headerFilter[col.headerFilter]) { + + // Assign custom headerFilter from ui utils. + col.headerFilter = + mapp.ui.utils.tabulator.headerFilter[col.headerFilter](_this); + } + + // Check for custom formatter in the ui utils. + if (typeof col.formatter === 'string' + && mapp.ui.utils.tabulator.formatter[col.formatter]) { + + // Assign custom formatter from ui utils. + col.formatter = + mapp.ui.utils.tabulator.formatter[col.formatter](_this); + } + } } function like(_this) { @@ -27,7 +62,7 @@ function like(_this) { // Set filter _this.layer.filter.current[field] = { - [headerFilterParams.type || 'like']: encodeURIComponent(e.target.value) + [headerFilterParams.type || 'like']: e.target.value } } else { @@ -325,7 +360,6 @@ function set(_this) { options.length && _this.Tabulator.addFilter(field, 'in', options) } } - } function select(_this, params = {}) { @@ -361,7 +395,6 @@ function select(_this, params = {}) { // Remove selection colour on row element. row.deselect(); } - } function toLocalString(_this) { @@ -374,7 +407,6 @@ function toLocalString(_this) { return val.toLocaleString(formatterParams?.locale || 'en-GB', formatterParams?.options) } - } function date(_this) { @@ -389,5 +421,4 @@ function date(_this) { return str } - } \ No newline at end of file diff --git a/lib/utils/gazetteer.mjs b/lib/utils/gazetteer.mjs index e8c2b8775..a19871e3f 100644 --- a/lib/utils/gazetteer.mjs +++ b/lib/utils/gazetteer.mjs @@ -7,11 +7,22 @@ export function datasets(term, gazetteer) { } // Search additional datasets. - gazetteer.datasets?.forEach(search) + gazetteer.datasets?.forEach(dataset => search({ + layer: gazetteer.layer, + table: gazetteer.table, + query: gazetteer.query, + qterm: gazetteer.qterm, + label: gazetteer.label, + title: gazetteer.title, + limit: gazetteer.limit, + leading_wildcard: gazetteer.leading_wildcard, + callback: gazetteer.callback, + maxZoom: gazetteer.maxZoom, + ...dataset})) function search(dataset) { - let layer = gazetteer.mapview.layers[gazetteer.layer || dataset.layer] + const layer = gazetteer.mapview.layers[dataset.layer] // Skip if layer defined in datasets is not added to the mapview if (!layer) { @@ -21,7 +32,7 @@ export function datasets(term, gazetteer) { } // Skip if layer table is not defined and no table is defined in dataset or gazetteer. - if (!layer.table && !dataset.table && !gazetteer.table) { + if (!layer.table && !dataset.table) { console.warn('No table definition for gazetteer search.') return; @@ -32,18 +43,19 @@ export function datasets(term, gazetteer) { dataset.xhr = new XMLHttpRequest() - dataset.xhr.open('GET', gazetteer.mapview.host + '/api/query/gaz_query?' + + dataset.xhr.open('GET', gazetteer.mapview.host + '/api/query?' + mapp.utils.paramString({ - label: dataset.qterm, + template: dataset.query || 'gaz_query', + label: dataset.label || dataset.qterm, qterm: dataset.qterm, qID: layer.qID, locale: gazetteer.mapview.locale.key, layer: layer.key, filter: layer.filter?.current, - table: dataset.table || gazetteer.table || layer.table, + table: dataset.table || layer.table, wildcard: '*', term: `${dataset.leading_wildcard ? '*' : ''}${term}*`, - limit: dataset.limit || gazetteer.limit || 10 + limit: dataset.limit || 10 })) dataset.xhr.setRequestHeader('Content-Type', 'application/json') @@ -71,12 +83,12 @@ export function datasets(term, gazetteer) {
  • { - if (gazetteer.callback) return gazetteer.callback(row, gazetteer); + if (dataset.callback) return dataset.callback(row, dataset); mapp.location.get({ layer, id: row.id - }).then(loc => loc && loc.flyTo(gazetteer.maxZoom || dataset.maxZoom)) + }).then(loc => loc?.flyTo?.(dataset.maxZoom)) }}> ${dataset.title || layer.name} diff --git a/lib/utils/paramString.mjs b/lib/utils/paramString.mjs index f9466e469..b3e92e067 100644 --- a/lib/utils/paramString.mjs +++ b/lib/utils/paramString.mjs @@ -1,6 +1,10 @@ // Create param string for XHR request. export default params => Object.entries(params) + + // Value should be 0 or truthy .filter(entry => entry[1] === 0 || !!entry[1]) + + // Value must not be empty functional brackets. .filter(entry => entry[1] !== '{}') // Filter out zero length array and objects with empty object values. @@ -10,16 +14,10 @@ export default params => Object.entries(params) .map(entry => { - // if (Array.isArray(entry[1])) { - - // return entry[1].map(val => `${entry[0]}=${val}`).join('&') - // } - // Stringify non array objects. if (typeof entry[1] === 'object' && !Array.isArray(entry[1])) { - entry[1] = JSON.stringify(entry[1]) - + return `${entry[0]}=${encodeURIComponent(JSON.stringify(entry[1]))}` } return encodeURI(`${entry[0]}=${entry[1]}`) diff --git a/mod/fetch.js b/mod/fetch.js index 4ed161412..cbb8d4ab5 100644 --- a/mod/fetch.js +++ b/mod/fetch.js @@ -1,5 +1,10 @@ module.exports = async (req, res) => { + if (!req.options?.template) { + res.status(400).send(); + return; + } + const template = req.options.template if (typeof template.options.body !== 'string') { diff --git a/mod/utils/sqlFilter.js b/mod/utils/sqlFilter.js index 9f1d10373..259083e98 100644 --- a/mod/utils/sqlFilter.js +++ b/mod/utils/sqlFilter.js @@ -21,7 +21,7 @@ const filterTypes = { `(${val .split(',') .filter((val) => val.length > 0) - .map((val) => `"${col}" ILIKE \$${addValues(`${val}%`, true)}`) + .map((val) => `"${col}" ILIKE \$${addValues(`${val}%`)}`) .join(' OR ')})`, match: (col, val) => `"${col}"::text ILIKE \$${addValues(val)}` @@ -29,12 +29,9 @@ const filterTypes = { let SQLparams -function addValues(val, skip) { +function addValues(val) { - SQLparams.push(Array.isArray(val) - && val[0].map(v=>decodeURIComponent(v)) - || skip && val - || decodeURIComponent(val)) + SQLparams.push(val) return SQLparams.length }