-
Notifications
You must be signed in to change notification settings - Fork 26
Application Views
MAPP application views are web pages consisting of markup, script, the Openlayers, and MAPP libraries. Application view may be designed as reports or dashboards.
Application views may be stored as templates allowing access through the View API.
The XYZ API uses minimalist markup templating. Parameter in double braces will be substituted if matched or removed before the HTML response is sent.
<!DOCTYPE html>
<html lang="{{language}}">
data-* attributes can be assigned to the document <head>
. The data-user attribute value can be accessed from script like so: document.head.dataset.user
The {{title}}
parameter will be substituted with the value from the title environment variable used to initialise the XYZ node process.
The {{dir}}
parameter will be substituted with the value from the dir environment variable which defines the URL root path for the XYZ API.
The Openlayers library is not bundled with, and must be referenced prior to loading the MAPP library and script.
Both the MAPP and MAPP.UI libraries have their own stylesheets which must be referenced in the document head.
Additional styles can defined in a style block in the document head. Because DRY; These should only be styles which are used in a specific application view context and are not required by other HTML templates.
<head
data-dir="{{dir}}"
data-user="{{user}}"
data-login="{{login}}"
data-locale="{{locale}}">
<title>{{title}}</title>
<link rel="icon" type="image/x-icon" href="{{dir}}/icons/favicon.ico" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.12.0/build/ol.js" defer></script>
<!-- Load XYZ / MAPP stylesheet and library. -->
<link rel="stylesheet" href="{{dir}}/css/mapp.css" />
<link rel="stylesheet" href="{{dir}}/css/ui.css" />
<script type="module" src="{{dir}}/js/lib/mapp.js" defer></script>
<script type="module" src="{{dir}}/js/lib/ui.js" defer></script>
<script src="{{dir}}/views/_default.js" defer></script>
<style>
html {
height: 100%;
}
</style>
</head>
Most elements can and will be generated with µhtml methods which are bundled as MAPP.utils. The .map-container
element is the target for a Mapview with an Openlayers Map. We believe in giving proper attribution to all data and code and put an .attribution-links
div inside the mapview to display attribution for code libraries used, and data sources displayed.
<body>
<div id="Map" class="map-container">
<div class="attribution-links"></div>
</div>
</body>
MAPP script may be referenced as a script file in the document head or in a script block after the document body. The script should be executed once the document window has loaded. The MAPP library requires an ES6+ Javascript environment and makes use of promises throughout. Personal preferance is for an async/await script structure as it improves the readability of the code.
We recommend to assign the application host path as a data attribute to the document head if indeed a an URL path is used to extent the application domain. It is also valid to hardcode the host domain and path. The host is relative and only the dir
value is required if the view is accessed through the View API.
Any URL parameter are stored as key/values in the mapp.hooks.current object once the Mapp library is assigned to the document window. This allows us to define the map viewport, which layers to load from which locale, and which locations to select in the application view script.
A locale must be loaded prior to initiating the mapview object.
A list of locales can be requested from the Workspace API /locales. e.g. https://geolytix.dev/latest/api/workspace/locales
The locale JSON can be requested from the Workspace API /locale. e.g. https://geolytix.dev/latest/api/workspace/locale?locale=UK
The mapp.Mapview decorator will create an Openlayers Map object in the defined target element.
const mapview = mapp.Mapview({
host: host,
target: 'Map',
locale: locale,
attribution: {
target: document.querySelector('#Map > .attribution-links'),
links: {
[`XYZ v${mapp.version}`]: "https://geolytix.github.io/xyz",
Openlayers: "https://openlayers.org",
},
}
})
The view of a mapview.Map may already be set in the locale used to decorate the mapview object. After initialisation it is possible to set the view directly by either defining the view's zoom or centre. The centre must be defined as coordinates in the mapview's projection which is 3857 by default. It may be necessary to transform geographic coordinates to cartographic with ol.proj.transform. A mapview.map view can also be fitted to an extent from a vector source.
// Set zoom...
mapview.Map.getView().setZoom(12)
// and centre.
mapview.Map.getView().setCenter(ol.proj.transform([51.5263,-0.1802],'EPSG:4326','EPSG:3857'))
// Set view from extent.
mapview.fitView(vectorSource.getExtent())
The locale JSON contains an array of layer keys. The layer JSON for an individual layer can be requested from the Workspace API /layer e.g. https://geolytix.dev/latest/api/workspace/layer?locale=UK&layer=scratch
The xhr util maybe used to request the layer JSON from the Workspace API.
The promiseAll util maybe used to resolve an array of xhr requests for layer JSON.
A single (or an array of) JSON layer maybe passed to the mapview.addLayer() method which will decorate the layer objects and assign the layer to the mapview.layers object.
const layers = await mapp
.utils.promiseAll(locale.layers.map(layer => mapp
.utils.xhr(`${host}/api/workspace/layer?locale=${locale.key}&layer=${layer}`)))
await mapview.addLayer(layers)
The mapp.location.get requires a decorated layer object and a location id to retrieve the associated data from the Location API and decorate the location object. The decorated location is assigned to the mapview.locations object of the location layer's mapview.
const location = await mapp.location.get({
layer: locationMap.layers['sites'],
id: mapp.hooks.current.id
})
It is possible to get a location for each hook in current.locations. A location hook is constructed from the layer key and the location id seperated by an exclamation mark.
mapp.hooks.current.locations.forEach((_hook) => {
const hook = _hook.split("!")
mapp.location.get({
layer: mapview.layers[decodeURIComponent(hook[0])],
id: hook[1],
})
})
A grid view of a location's infoj entries may be created with the locations.info UI utility.
let locationview = mapp.ui.locations.infoj(location)
tbc
tbc
The loadPlugins utility tool may be used to load plugins from an array of ressource links. The default script will load all plugins which are defined in the current locale.
await mapp.utils.loadPlugins(locale.plugins);
tbc
This section will outline how we go about creating custom views (reports) using the XYZ Framework.
- The first stage of the set-up is to create a
html
file that we will populate
<!DOCTYPE html>
<html lang="{{language}}">
<head data-dir="{{dir}}" data-user="{{user}}" data-login="{{login}}" data-locale="{{locale}}">
<title>Site Assessment Report</title>
<link rel="icon" type="image/x-icon" href="{{dir}}/public/icons/favicon.ico" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- The OL library is required for any map and should not be imported dynamically. -->
<script src="https://cdn.jsdelivr.net/npm/ol@v7.3.0/dist/ol.js" defer></script>
<!-- The MAPP library is required for the map control. -->
<link rel="stylesheet" href="{{dir}}/public/css/MAPP.css" />
<script type="module" src="{{dir}}/public/js/lib/MAPP.js" defer></script>
<!-- This report will use charts;
Dynamic import will be skipped if the chart library is already assigned to the window. -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- The MAPP.ui library is required from interface elements such as data and location views. -->
<link rel="stylesheet" href="{{dir}}/public/css/ui.css" />
<script type="module" src="{{dir}}/public/js/lib/ui.js" defer></script>
<!-- Custom CSS will be added here to design the Report Layout -->
<style></style>
<!-- Custom HTML will be added here to set up the Report Layout -->
<body>
</body>
<!-- Custom JS will be added here to add elements into the divs defined in the body -->
<script>
window.onload = async () => { };
</script>
</head>
</html>
- Add this to the
workspace.json
in thetemplates
section.
"site_report": {
"src": "${TEMPLATES}/folder/report.html"
}
- Then call it in the
infoj
of thelayer.json
file.
{
"type": "report",
"report": {
"label": "Report",
"template": "site_report"
}
},
.map {
height: 100%; /* Needs a height to display */
min-height: 25vh; /* Ensures a minimum height */
width: 100%; /* Needs a width to display */
position: relative; /* Ensures elements can use it for absolute positioning */
}
.header {
height: 40px; /* Sets the height of the header element to 40 pixels */
display: flex; /* Displays the elements within the header as a flex container */
align-items: center; /* Vertically aligns the elements within the header */
justify-content: space-between; /* Distributes the elements evenly along the horizontal axis */
padding: 0 10px; /* Adds 0 pixels of padding on the top and bottom, and 10 pixels of padding on the left and right */
}
.page {
width: 210mm; /* Sets dimensions for a4 page */
height: 297mm; /* Sets dimensions for a4 page */
margin: 5mm auto; /* Sets dimensions for a4 page */
padding: 5mm; /* Sets dimensions for a4 page */
page-break-after: auto; /* Sets dimensions for a4 page */
box-shadow: 1px 1px 5px #999; /* Gives a shadow effect for the page */
display: flex; /* Displays the elements within the title-container as a flex container */
flex-direction: column; /* Arranges the elements vertically within the title-container */
gap: 3mm; /* Sets dimensions for a4 page */
}
.title-container {
display: flex; /* Displays the elements within the title-container as a flex container */
justify-content: center; /* Horizontally centers the elements within the title-container */
align-items: flex-start; /* Aligns the elements to the top within the title-container */
flex-direction: column; /* Arranges the elements vertically within the title-container */
margin-bottom: 5px; /* Adds 5 pixels of margin at the bottom of the title-container */
}
.container {
display: flex; /* Displays the elements within the title-container as a flex container */
justify-content: center; /* Horizontally centers the elements within the title-container */
align-items: flex-start; /* Aligns the elements to the top within the title-container */
gap: 10px; /* Applies a gap around the container */
}
.container>* {
width: 50%; /* Items inside the container side by side take up half the page */
}
.container1-3 {
width: 40%; /* Change the width of the item to 40% */
}
.container2-3 {
width: 60%; /* Change the width of the item to 60% */
}
h1 {
font-size: 2em; /* Sets the font size of h1 to 2em (equivalent to 32 pixels) */
font-weight: 600; /* Sets the font weight of h1 to 600 (bold) */
align-self: center; /* Horizontally centers the h1 element within its container */
}
.svg1,
.svg2 {
max-height: 40px; /* Sets the maximum height of svg1 and svg2 to 40 pixels */
width: auto; /* Allows the width of the svg elements to adjust automatically */
}
.svg1 {
margin-right: 10px; /* Adds 10 pixels of margin on the right side of svg1 */
}
.svg2 {
margin-left: 10px; /* Adds 10 pixels of margin on the left side of svg2 */
}
@media print { /*Media queries for print to pdf */
@page {
size: A4 portrait;
margin: 0;
}
.page {
margin: 0;
box-shadow: none;
print-color-adjust: exact;
webkit-print-color-adjust: exact;
}
#backspacer {
display: none;
}
}
<header>
<img class="svg1" viewBox="0 0 40 40" src="brand1.svg"></img>
<div class="title-container">
<h1>TITLE</h1>
<span class="date" style="align-self: center;"></span>
</div>
<img class="svg2" viewBox="0 0 40 40" src="brand2.svg"></img>
</header>
.subheader {
padding: 10px;
text-align: left;
background: #000; /* Replace with hex code of choice */
color: white;
font-size: 15px;
margin-bottom: 5px;
box-shadow: 1px 1px 5px #999; /* Adds a slight shadow to the box */
}
/* Overrides the background color of the column groups in the Tabulator component */
.tabulator [role="columngroup"] {
background-color: #000 !important; /* Replace with hex code of choice */
}
/* Overrides the background color of the column headers in the Tabulator component */
.tabulator [role="columnheader"] {
background-color: #000 !important; /* Replace with hex code of choice */
}
/* Overrides the color of the column titles in the Tabulator component */
.tabulator-header .tabulator-col .tabulator-col-title {
color: #fff; /* Replace with hex code of choice */
}
/* end tabulator overrides */
/////////////////////// INITIAL SET UP ///////////////////////
// The view endpoint will assign the url path as dir parameter.
const host = document.head.dataset.dir;
// Assign the current date to the page header.
document.querySelectorAll(".date").forEach(
(element) =>
(element.textContent = new Date().toLocaleDateString("en-EN", {
year: "numeric",
month: "short",
day: "numeric",
}))
);
// Should be hardcoded if known which locale is used.
const locale = await MAPP.utils.xhr(
`${host}/api/workspace/locale?locale=UK`
);
// load locale from hook.
//.xhr(`${host}/api/workspace/locale?locale=${MAPP.hook.current.locale}`);
await MAPP.utils.loadPlugins([locale.plugins[0]]);
// Execute plugins
Object.keys(locale).forEach((key) => {
MAPP.plugins[key] && MAPP.plugins[key](locale[key], null);
});
// Load selected layers. Use locale.layers to load all.
const layers = await MAPP.utils.promiseAll(
[
"layer_name_one",
"layer_name_two",
"layer_name_three",
"sites"
].map((layer) =>
MAPP.utils.xhr(
`${host}/api/workspace/layer?` +
`locale=${locale.key}&layer=${layer}`
)
)
);
// Create the sites layer as a hidden layer
hiddenLayersSites = new Set(["sites"]);
- To create a new Map in a report, you need to define the
target
which is a named div element in the html file body. - Then you need to add layers to this using
.addLayer
on the name of that map object, adding the name of all layers you wish to display on that map into thenew Set([])
. - By default, the
sites
layer (this should be the layer the report is generated from), is set to display false. - Then, we select the particular ID of the location (equivalent to clicking on it in the central MAPP window).
- Then, we assign the new map created to the location we have just selected.
// Create mapview.
const locationMap = MAPP.Mapview({
host: host,
target: "location-map",
locale: locale,
scalebar: "metric",
scrollWheelZoom: true,
});
// Filter layers array and add layers for display
await locationMap.addLayer(
layers
.filter((layer) =>
new Set(["mapbox_base", "mapbox_label", "sites"]).has(layer.key)
)
.map((layer) => {
layer.display = !hiddenLayersSites.has(layer.key);
return layer;
})
);
// Get store location
const location = await MAPP.location.get({
layer: locationMap.layers["sites"],
id: MAPP.hooks.current.id,
});
// Assign mapview and layer to location.
location.layer = locationMap.layers["sites"];
// Get the layer
const exampleLayer = layers.find(
(layer) => layer.key === "example_layer"
);
// Set the new layer zoom by providing a table for the query response
exampleLayer.tables = {
6: null,
7: "schema_name.example_table"
};
- This section will first remove editable ability from
infoj
elements, as the Report should be a summary and not editable. - Then, we create an array by filtering from the
infoj
all the entries we wish to display in the report using their field value to do so inside anew Set([])
. - (optional) - We can then remove the group from the
infoj
elements, which will remove the Group Dropdown from display. - Then, we append this into a named div from the HTML file, in this case
location-info
.
// Remove editable fields in report.
location.infoj.forEach((entry) => delete entry.edit);
// Create an array of filtered entries for the location view
const locationInfoArray = location.infoj.filter((entry) =>
new Set([
"field_one",
"field_two"
]).has(entry.field)
);
// Remove groups from filtered entries
locationInfoArray.forEach((entry) => delete entry.group);
// Create and append location info.
document
.getElementById("location-info")
.append(MAPP.ui.locations.infoj(location, locationInfoArray));
- By default,
mapview
interactions are turned off on custom views. - This means that hovering over locations on these mapviews will not do anything.
- It is possible to turn this on, so the user can hover over points to see
hover
labels and highlights for example. - You will need to change
Map
to the name of themapview
for each map you wish to enable interactions on.
// Enable hover interaction on the map
Map.interactions.highlight();
- It is possible to set the Zoom Level of the Report and centre on the location.
- set the zoom to 10 using
setZoom()
; - Centre to the location pin, which needs to be in the projection
EPSG:3857
, so this example is transformed fromEPSG:4326
.
// Set zoom...
locationMap.Map.getView().setZoom(10);
// and centre from location pin.
locationMap.Map.getView().setCenter(
ol.proj.transform(
location.infoj.find((entry) => entry.field === "pin").value,
"EPSG:4326",
"EPSG:3857"
)
);
- It is possible to add geometries from the
infoj
to the Maps on the Report. - First, select the geometries from the
infoj
usingnew Set([])
and filtering on the field value in theinfoj
. - Then, we draw them to the map.
- Then, we use
flyTo()
to fly to the extent of the geometries.
// Filter geometry entries from location.
let locationGeometryEntries = location.infoj.filter((entry) =>
new Set(["pin", "isoline_5min", "isoline_10min", "isoline_15min"]).has(
entry.field
)
);
// Draw geometries to map.
MAPP.ui.locations.infoj(location, locationGeometryEntries );
// Fly to extent of the draw location geometries.
location.flyTo();
- It is possible to add
infoj
geometry elements to the maps in a Report. - This is very useful as it allows the geometries to be shown in a legend, and also allows layer toggles to include geometries from that sites
infoj
.
// Grab the selected catchment from the location.
const selectedCatchmentChoice = location.infoj.find(
(entry) => entry.field === "comparison_isochrone"
);
// Lookup the selected catchment against the infoj field name
selectedCatchmentLookup = {
"Geom_1": {
geom: "geom_1",
type: "Geom_1",
},
"Geom_2": {
geom: "geom_2",
type: "Geom_1",
},
};
// Create a layer to hold the Selected Catchment
const selectedCatchment = {
key: "selectedCatchment",
name: "Report Isochrone",
display: true,
format: "geojson",
srid: "4326",
zIndex: 99,
style: {
theme: {
type: "categorized",
field: "type",
cat: {
"Geom 1": {
style: {
strokeColor: "#000000",
strokeWidth: 1,
fillColor: "#F49D37",
fillOpacity: 0.2,
},
},
"Geom 2": {
style: {
strokeColor: "#000000",
strokeWidth: 1,
fillColor: "#F49D37",
fillOpacity: 0.2,
},
},
},
},
},
};
// filter the infoj to get the selected catchment
let catchmentGeom = location.infoj.find(
(entry) =>
entry.field ===
selectedCatchmentLookup[selectedCatchmentChoice.value].geom
).value;
catchmentGeom = JSON.parse(catchmentGeom)
// Add feature to layer source.
selectedCatchment.features = [{
type: 'Feature',
geometry: catchmentGeom,
id: 1,
properties: {
type: selectedCatchmentLookup[selectedCatchmentChoice.value].type,
}
}]
// Add the selected catchment to the map
await locationMap.addLayer([selectedCatchment]);
Steps to produce this:
- You will need to get the relevant value from the
infoj
of thelocation
. This could be a field that holds the name of the geometry or just selecting a single geometry for use. - Then, create a new
geoJSON
layer. - Then, create the layer config, giving the layer a
key
,name
,srid
,format
, andstyle
either using a default style if you don't have multiple options, or acategoric
theme if you do. - Then, Get the value of that particular geometry from the
infoj
, either using a lookup where you have multiple options, or simply by selecting using thefield
from theinfoj
. - Then, parse the geometry to JSON to enable it to be drawn as a geometry on the map.
- Then, we add the geometry to the
features
of the layer, giving it an arbitrary id of 1, and providing the value to style on in theproperties
. - Finally, we add the layer to the map (locationMap in this example) in question.
- It is possible to add Legends to the maps in a Report.
- First, create a legend objectselecting the layer from the
locationMap
(in this case "sites") and selecting the style theme. - Then, append this object to the
location-map
div element, with a class of "map legend box-shadow".
// Create legend for map.
const locationMap_legend = MAPP.ui.layers.legends[
locationMap.layers["sites"].style.theme.type
](locationMap.layers["sites"]);
// Add legend to retail map.
document.getElementById("location-map").append(MAPP.utils.html.node`
<div class="map legend box-shadow">${locationMap_legend }`);
- Its possible to add multiple layers to the same legend
- First, create a legend array of the layers.
- Then, append this object to the
location-map
div element, with a class of "map legend box-shadow".
// Add the layers needed for the legend and push them on the legend array
const poiLayers = new Set(["layer1", "layer2"])
map_legend = [];
poiLayers.forEach(
entry => {
map_legend .push(MAPP.ui.layers.legends[
map.layers[entry].style.theme.type
](map.layers[entry]));
}
);
// Add legend.
document.getElementById("location-map").append(MAPP.utils.html.node`
<div class="map legend poi box-shadow">${map_legend}`);
- It is possible to get
infoj
dataview elements from the location and call directly into the Report. - As such, all queries used to create charts or tables should be added to the
layer.json
directly, tested and then addskipEntry:true
to hide them from theinfoj
panel. - Here, you simply need to write the name of the target for the
dataview
into either the Tables section or Charts section below, and then will be drawn into the div element in the report directly.
//////////////// DATAVIEWS ////////////////
// Create tables.
location.infoj
.filter((entry) =>
new Set([
"location-table",
]).has(entry.target)
)
.forEach((entry) =>
MAPP.ui.Dataview(entry).then((table) => table.update())
);
// Create charts.
location.infoj
.filter((entry) =>
new Set([
"location-chart",
]).has(entry.target)
)
.forEach((entry) =>
MAPP.ui.Dataview(entry).then((chart) => chart.update())
);
- Should you choose to change the colors of a chart to suit a report you must call ChartJs.update() rather than just chart.update() since the colours come from the datasets and not the chart options.
// Find Chart from infoj
const chartName = location.infoj.find((i) => i.target === "chart-target");
// Load chart
await MAPP.ui.Dataview(chartName);
// Load Data
await chartName.update();
// Change colour after a slight delay
chartName.ChartJS.config._config.data.datasets[0].borderColor = "#F2FC"; // Replace with chosen hex code
chartName.ChartJS.config._config.data.datasets[0].backgroundColor = "#F2FC"; // Replace with chosen hex code
chartName.ChartJS.update();
This code section allows you to select multiple layers to toggle their display on the map within the report.
const layer_dropdown = MAPP.ui.elements.dropdown({
multi: true,
// Create entries from mapview.layers object values.
entries: Object.values(locationMap.layers).map(layer => ({
title: layer.name,
option: layer.key,
selected: layer.display
})),
// Options are an array of selected layer keys.
callback: (e, options) => {
// Iterate through the mapview layer.
Object.values(locationMap.layers).forEach(layer => {
// Show layer if in set from options has layer.key
// Otherwise hide layer.
new Set(options).has(layer.key) ? layer.show() : layer.hide();
})
}
});
// Output to report location
document.getElementById("dropdown-element").innerHTML = layer_dropdown;
-
Update
locationMap
to the name of theMapview
you have created, and this will be defined. -
You will then need to output this dropdown element into a named
div
element.